Переклад українською - Арсеній Чеботарьов - Ніжин 2016
Оригінал http://danielwestheide.com/scala/neophytes.html

The Neophyte's Guide to Scala

Частина 1: Екстрактори

Більше ніж 50,000 людей записались на курс Odersky  “Functional Programming Principles in Scala” на Coursera. Це гігантське число розробників, для яких це могло бути першим контактом зі Scala, функціональним програмуванням, або обома одночасно.

Якщо ви читаєте це, можливо, ви один з них, або ви почали вивчати Scala з інших причин. В жодному разі, якщо ви почали вивчати Scala, ви бажаєте поглинути глибше в цю прекрасну мову, але вона все ще видається трохи екзотичною або туманною для вас. Тоді ці статті, що я розпочинаю, саме для вас. 

Навіть коли курс Coursera торкається загалу, який ви маєте знати про Scala, існуючі обмеження часу роблять неможливим пояснити все в деталях. Як результат, деякі можливості Scala можуть ввижатись вам як магія, якщо ви нові в цій мові. Ви можете використовувати їх деяким чином, але ви не повністю схопили як вони роблять, та, більш важливо, чому вони роблять таким чином.

В цій частині, та в наступних тижні, я хочу прояснити речі та видалити знаки запитання. Я буду також пояснювати деякі з можливостей мови Scala та бібліотеки, з якими я мав проблеми, коли я починав навчатись мові. Частково тому, що я не знайшов гарних пояснень до них, але замість цього натрапив на них в дикій природі. Коли це стосується, я також надаватиму інструкції, як використовувати ці можливості ідіоматичним™ шляхом.

Досить вступів. Перед тім, як я почну, майте на увазі, що хоча проходження слухачем курсу Coursera не є попередньою вимогою для слідування цій серії, мати знання Scala, що може бути отримано з лекцій, безумовно, буде корисне. Та я буду часом посилатись на курс.

То як насправді робить ця штуковина з порівнянням шаблонів?

В курсі Coursera ви натрапите на дуже потужну можливість мови Scala: порівняння з шаблоном. Це дозволяє вам декомпонувати дані структури даних, прикріпляючи значення, з яких вони побудовані, до змінних. Однак це не уникальна до Scala ідея. Інші помітні мови, в яких порівняння з шаблоном відіграють важливу грають  роль, це Haskell та Erlang, як для прикладу.

Якщо ви слідували відео лекціям, ви бачили, що ви можете декомпонувати різні типи структур даних з використанням порівнянням шаблонів, серед яких списки, потоки, та любі примірники кейс класів. Чи є цей перелік структур даних, що можна деструктурувати, є фіксованим, чи ви можете розширити його якимось чином? Але спершу, як це в дійсності це дійсно робить? Чи є деяка магія, що дозволяє вам писати речі як нижче?

1
2
3
4
5
case class User(firstName: String, lastName: String, score: Int)
def advance(xs: List[User]) = xs match {
  case User(_, _, score1) :: User(_, _, score2) :: _ => score1 - score2
  case _ => 0
}

Як це з'ясовується, нічого такого немає. Або не багато. Причина, чому ви в змозі писати код вище (не важливо, як багато сенсу має цей окремий приклад), це існування так званих екстракторів.

В найбільш широко застосованій формі, екстрактор має зворотню до конструктора роль: коли останній створює об'єкти з наданого списку параметрів, екстрактор виділяє параметри, з яких був стіорений об'єкт при створенні.

Бібліотека Scala має декілька попередньо визначені екстракторів, та ви скоро побачите один з них. Кейс класи є особливими, бо Scala автоматично створює компанйон об'єкт для них: об'єкт-синглтон, що містить не тільки метод apply для створення нових примірників кейс класу, але також метод unapply  – метод, що потрібно реалізувати об'єкту, щоб він мав екстрактор. 

Наш перший екстрактор, йо!

Є  більше однієї можливої сигнатури для валідного метода unapply, але ми почнемо з тих, що, найбільш вірогідно, будуть використовуватись. Давайте уявимо, що наш клас User не є кейс класом взагалі, але замість цього є трейтом, з двома класами, що розширюють його, та на момент, він містить тільки одне поле:

1
2
3
4
5
trait User {
  def name: String
}
class FreeUser(val name: String) extends User
class PremiumUser(val name: String) extends User

Ми бажаємо реалізовати екстрактори для класів FreeUser та PremiumUserв відповідних об'єктах-компанйонах, саме так, як би зробила Scala в ціх кейс класах. Якщо ваш екстрактор призначений тільки для виділення одного параметру з наданого об'єкту, сигнатура метода unapplyвиглядає так:

1
def unapply(object: S): Option[T]

Метод очікує деякий об'єкт типу S, та повертає Option типу T, що є типом параметра, який він екстрагує. Пам'ятайте, що Optionє безпечною альтернативою Scala до існування значень null. Про це буде окрема стаття, але наразі досить знати, що метод unapplyповертає або Some[T] (якщо він може успішно виділити параметр з даного об'єкта) або None, що означає, що параметри не можуть бути виділені за правилами, визначеними в реалізації екстрактора.

Ось наші екстрактори:

1
2
3
4
5
6
7
8
9
10
11
12
trait User {
  def name: String
}
class FreeUser(val name: String) extends User
class PremiumUser(val name: String) extends User

object FreeUser {
  def unapply(user: FreeUser): Option[String] = Some(user.name)
}
object PremiumUser {
  def unapply(user: PremiumUser): Option[String] = Some(user.name)
}

Ми можемо тепер використовувати їх в REPL:

1
2
scala> FreeUser.unapply(new FreeUser("Daniel"))
res0: Option[String] = Some(Daniel)

Але ви звичайно не будете викликати їх напряму. Scala викликає метод екстрактора   unapply, якщо екстрактор використовується як шаблон екстрактора.

Якщо результат виклику  unapply є Some[T], це означає, що шаблон співпадає, та виділене значення прив'язується до змінної, задекларованої в шаблоні. Якщо це None, це означає, що шаблон не співпав, та буде перевірятись наступна умова співпадіння.

Давайте використаємо наші екстрактори для порівняння з шаблоном:

1
2
3
4
5
val user: User = new PremiumUser("Daniel")
user match {
  case FreeUser(name) => "Hello " + name
  case PremiumUser(name) => "Welcome back, dear " + name
}

Як ви вже помітили, обоє наші екстрактори ніколи не повертають None. Приклад показує, що це має більше сенсу, ніж може вважатись спочатку. Якщо ви маєте об'єкт, що може бути одного типу, або іншого, ви можете перевірити його тип, та розкласти його в той же час.

В прикладі шаблон FreeUser не буде співпадати, оскільки він очікує об'єкт іншого типу, що ми можемо передати йому. Оскільки він бажає об'єкт типу FreeUser, не типу  PremiumUser, цей екстрактор навіть ніколи не викликається. Однак значення user тепер передається до метода unapplyметода компанйон-об'єкта PremiumUser, тому що цей екстрактор використовується в другому шаблоні. Цей шаблон співпасть, а повернуте значення буде прикріплене до параметру name.

Пізніше в цій статті ми побачимо приклад екстрактора, що не завжди повертає Some[T].

Виділення декількох значень

Тепер уявімо, що наші класи такі, що ми бажаємо виділяти з них більше число полів:

1
2
3
4
5
6
7
trait User {
  def name: String
  def score: Int
}
class FreeUser(val name: String, val score: Int, val upgradeProbability: Double)
  extends User
class PremiumUser(val name: String, val score: Int) extends User

Якщо шаблон екстрактора призначений для декомпозиції даної структури даних, де більше одного параметра, сигнатура метода екстрактора unapplyвиглядає так:

1
def unapply(object: S): Option[(T1, ..., Tn)]

Метод очікує деякий об'єкт типу  S, та повертає Option типу TupleN, де N є кількістю параметів до виділення.

Давайте адаптуємо наші екстрактори до модифікованих класів:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
trait User {
  def name: String
  def score: Int
}
class FreeUser(val name: String, val score: Int, val upgradeProbability: Double)
  extends User
class PremiumUser(val name: String, val score: Int) extends User

object FreeUser {
  def unapply(user: FreeUser): Option[(String, Int, Double)] =
    Some((user.name, user.score, user.upgradeProbability))
}
object PremiumUser {
  def unapply(user: PremiumUser): Option[(String, Int)] = Some((user.name, user.score))
}

Тепер ми можемо використовувати цей екстрактор для співпадіння шаблонів, точно як ми робили в попередній версії:

1
2
3
4
5
6
val user: User = new FreeUser("Daniel", 3000, 0.7d)
user match {
  case FreeUser(name, _, p) =>
    if (p > 0.75) name + ", what can we do for you today?" else "Hello " + name
  case PremiumUser(name, _) => "Welcome back, dear " + name
}

Логічний екстарктор

Іноді вам насправді не треба виділити параметри зі структури даних, з якою ви робите співпадіння – замість цього ви просто бажаєте виконати деяку логічну перевірку. В цьому випадку стане в нагоді третя, та остання з існуючих сигнатур метода unapply, що очікує значення значення типу S та повертає  Boolean:

1
def unapply(object: S): Boolean

Використаний в шаблоні, він дасть співпадіння, якщо екстрактор поверне true. Інакше буде спробоване наступне порівняння.

В попередньому прикладі ми мали деяку логіку, що перевіряла, чи простий користувач може бути підозрюваним в якості скорого кандидата на апгрейд. Давайте покладемо цю логіку в наш власний логічний екстрактор:

1
2
3
object premiumCandidate {
  def unapply(user: FreeUser): Boolean = user.upgradeProbability > 0.75
}

Як ви можете бачити, не є необхідним для екстрактора знаходитись в об'єкті-компанйоні класа, до якого він стосується. Використання цього логічного екстрактора є простим:

1
2
3
4
5
val user: User = new FreeUser("Daniel", 2500, 0.8d)
user match {
  case freeUser @ premiumCandidate() => initiateSpamProgram(freeUser)
  case _ => sendRegularNewsletter(user)
}

Цей приклад показує, що логічний екстратор використовується простою передачей йому пустого списку параметрів, що має сенс, бо насправді нема ніякого виділення жодних параметрів для прикріплення до змінних.

Є одна інша особливість цього приклада: я претендую на те, що наша функціональна функція initiateSpamProgram очікує екземпляр FreeUser, оскільки преміум користувачі ніколи не отримуватимуть спам. Однак, наше співпадіння виконується з шаблоном проти любого типу User, так що я не можу передавати user до функції initiateSpamProgram – не без огидного кастингу типів.

На щастя, порівняння шаблонів Scala дозволяє прикріпляти до змінної значення, що співпадають, також з використанням типу, що очікує використаний екстрактор. Це робиться за допомогою оператора @. Оскільки наш екстрактор premiumCandidate очікує примірник FreeUser, ми маємо, таким чином, прикріплення співпавшого значення до змінної freeUser типу FreeUser.

Персонально я не дуже багато використовував логічні екстрактори, але добре знати, що вони існують, бо раніше або пізніше ви опинитесь в ситуації, коли вони знадобляться. 

Шаблони інфіксних операторів

Якщо ви слідували за Scala курсом на Coursera, ви знаєте, що ви можете деструктувати списки та потоки в спосіб, що є спорідненим до того, як ви створюєте їх, використовуючи оператор cons , :: або #::відповідно:

1
2
3
4
5
val xs = 58 #:: 43 #:: 93 #:: Stream.empty
xs match {
  case first #:: second #:: _ => first - second
  case _ => -1
}

Можливо ви дивуєтесь, як це можливо. Відповідь полягає в тому, що в якості альтернативи до нотації шаблонів екстракторів, що ми вже бачили, Scala також дозволяє екстракторам бути задіяними в інфіксній нотації. Таким чином, замість написання e(p1, p2), де e є екстрактор, та p1 та p2 є параметри, що будуть виділені з наданої структури даних, завжди можливо записати p1 e p2.

Таким чином шаблон інфіксної операції head #:: tail може також бути записаний як #::(head, tail), та наш екстрактор PremiumUser також може бут використаний в шаблоні, що читаєься name PremiumUser score. Однак, це не щось, що вам треба робити на практиці. Використання шаблону інфіксного оператора рекомендований тільки для екстракторів, що дійсно призначені для читання як оператори, що вірне для операторів  cons List та Stream, але безсумнівно не для нашого екстрактора PremiumUser.

Ближчий погляд на екстрактор Stream

Навіть зважаючи, що немає нічого особливого в тому, як екстрактор #:: може бути задіяний в порівнянні шаблонів, давайте поглянемо на нього, щоб краще зрозуміти, що відбувається в нашому коді порівняння. Також це гарний приклад екстрактора, що, в залежності від стану переданої структури даних, може повертати None, і, таким чином, не співпадати.

Ось повний екстрактор, взятий з джерел Scala 2.9.2:

взяте з scala/collection/immutable/Stream.scala, (c) 2003-2011, LAMP/EPFL
1
2
3
4
5
object #:: {
  def unapply[A](xs: Stream[A]): Option[(A, Stream[A])] =
    if (xs.isEmpty) None
    else Some((xs.head, xs.tail))
}

Якщо даний примірник Stream пустий, він повертає None. Таким чином, case head #:: tail не співпадає для пустого потоку. Інакше повертається Tuple2, перший елемент якого є головою потоку, тоді як другий елемент кортежу є хвіст, що знову є Stream. Таким чином, case head #:: tail буде співпадати для потока з одного, або більше, елементів. Якщо він має тільки один елемент, tail буде прикріплений до пустого потока. 

Щоб зрозуміти, як цей екстрактор робить для нашого приклада співпадіння, давайте перепишемо цей приклад, йдучи від шаблону інфіксного операнду до звичайної нотації:

1
2
3
4
5
val xs = 58 #:: 43 #:: 93 #:: Stream.empty
xs match {
  case #::(first, #::(second, _)) => first - second
  case _ => -1
}

Спершу екстрактор викликається для початкового потокуxs, що переданий в блок порівняння. Екстрактор повертає Some((xs.head, xs.tail)), так що first прив'язане до  58, тоді як хвіст xs знову передається в екстрактор, що використовується знову в першому порівнянні. Знову, він повертає голову та хвіст, як Tuple2, огорнуте в Some, так що second прив'язане до значення 43, тодя як хвіст прив'язується до підстановочного символа _, і, таким чином, відкидається.

Використання екстракторів

Так що, коли і як ви, насправді, повинні використовувати власні ексрактори, особливо беручи до уваги, що ви можете отримати деякі корисні екстрактори задурно, якщо використовуєте кейс класи?

Хоча деякі люди вказують, що використання кейс класів, та застосування до них співпадіння шаблонів з ними ламає інкапсуляцію, пов'язуючи спосіб, що ви порівнюєте дані, з конкретним представленням, цей критицизим зазвичай походить від об'єктно-орієнтованого погляду на речі. Це гарна ідея, якщо ви бажаєте робити функціональне програмування в Scala, використовувати кейс класи як алгебраїчні типи даних (ADT), що містять тільки дані, але жодної поведінки.

Зазвичай, використання ваших власних екстракторів необхідне тільки в випадку, якщо ви бажаєте виділяти з типу те, над чим ви не маєте влади, або якщо вам потрібні додаткові шляхи співпадіння з шаблоном проти певних даних. Наприклад, загальним використанням екстракторів є виділення осмислених значень з деякого рядка. В якості вправи подумайте, як ви повинні реалізовати та використовувати URLExtractor, що приймає Stringпредставлення URL.

Висновок

В цій першій частині з серії ми розглянули екстрактори, робочу конячку за співпадінням шаблонів Scala. Ви навчились, як реалізувати ваші власні екстрактори, та як реалізація екстрактора пов'язана з його використанням в шаблоні.

Ми не розглянули все, що можна сказати про екстрактори, тому що ця стаття вже є досить довгою. В наступній частині з цієї серії я збираюсь повернутись до екстракторів, розповідаючи, як реалізувати їх, якщо ви бажаєте число змінних екстрагованих параметрів в шаблоні.

Дайте мені знати, чи ця стаття була корисною для вас, або дещо не було вам зрозуміло.

Частина 2: Екстракція послідовностей

В першій частині цієї серії ми вивчили, як втілити наші власні екстрактори, та як ці екстрактори можуть використовуватись для порівняння шаблонів. Однак ми дискутували тільки екстрактори, що дозволяють вам деструктурувати даний об'єкт в фіксоване число параметрів. Але для деяких різновидів структур даних Scala дозволяє вам виконати порівняння шаблонів, очікуючи довільне число виділених параметрів.

Наприклад, ви можете використовувати шаблон, що співпадає тільки зі списком, що містить рівно два елементи, або зі списком з рівно трьох елементів:

1
2
3
4
5
6
val xs = 3 :: 6 :: 12 :: Nil
xs match {
  case List(a, b) => a * b
  case List(a, b, c) => a + b + c
  case _ => 0
}

Ще більше, якщо ви бажаєте порівняти списки з довжиною, про яку ви не турбуєтесь, ви можете викоритовувати підкреслення _*:

1
2
3
4
5
val xs = 3 :: 6 :: 12 :: 24 :: Nil
xs match {
  case List(a, b, _*) => a * b
  case _ => 0
}

Тут перший шаблон співпадає, прикріплюючі перші два елементи до змінних a та b, та повністю ігноруючи решту списку, безвідносно до того, як багато лишилось тих елеметнів. 

Зрозуміло, екстрактори для ціх типів шаблонів не можуть бути реалізовані у спосіб, що я виклав в першій статті. Нам потрібен шлях вказати, що екстрактор приймає об'єкт певного типу, та деструктує його в послідовність виділених значень, де довжина цієї послідовності невідома під час компіляції.

ВведемоunapplySeq, метод екстарктора, що дозволяє робити семе це. Давайте поглянемо на одну з можливих сігнатур метода:

1
def unapplySeq(object: S): Option[Seq[T]]

Він очікує об'єкт типу S, та повертає або None, якщо об'єкт зовсім не співпадає, або послідовність виділеного типу T, огорнутого в Some.

Приклад: виділення наданих імен

Давайте використаємо цей тип методів екстрактора в наступному вигаданому прикладі. Скажімо, в деякій частині застосування ми отримуємо ім'я користувача як String. Цей рядок може містити друге, або третє ім'я користувача, якщо він має більше одного наданого імені. Таким чином, можливі значення можуть бути "Daniel", або"Catherina Johanna", або "Matthew John Michael". Ми бажаємо мати змогу порівняти ці імена, виділяючи та прив'язуючи окремі надані імена.

Ось дуже проста реалізація екстрактора в термінах метода unapplySeq, що дозволить нам зробити це:

1
2
3
4
5
6
object GivenNames {
  def unapplySeq(name: String): Option[Seq[String]] = {
    val names = name.trim.split(" ")
    if (names.forall(_.isEmpty)) None else Some(names)
  }
}

Беручи String, що містить одне або більше імен, це буде виділяти їх як послідовність. Якщо вхідне ім'я не містить щонайменьше одного наданого імені, цей екстрактор буде повертати None, і, таким чином, шаблон в цьому використаному екстракторі не буде співпадати з рядком.

Тепер ви можете надати наш новий екстрактор до тесту:

1
2
3
4
def greetWithFirstName(name: String) = name match {
  case GivenNames(firstName, _*) => "Good morning, " + firstName + "!"
  case _ => "Welcome! Please make sure to fill in your name!"
}

Цей вправний малий метод повертає привітання для наданого імені, ігноруючи все, крім першого імені. greetWithFirstName("Daniel") буде повертати "Good morning, Daniel!", а greetWithFirstName("Catherina Johanna") поверне "Good morning, Catherina!"

Комбінація екстракції фіксованих та змінних параметрів

Іноді ви маєде деякі значення, що мають бути виділені, що ви знаєте під час компіляції, плюс додаткові опціональні послідовності значень.

Давайте уявімо в нашому прикладі, що вхідне ім'я містить повне ім'я, не тільки надане. Можливі значення можуть бути "John Doe" або"Catherina Johanna Peterson". Ми бажаємо порівнювати такі рядки з використанням шаблонів, що завжди прикріплює останнє ім'я людини до першої змінної в шаблоні, та перше ім'я до другої змінної, за якими слідує довільне число додаткових наданих імен.

Це може бути досягнено за допомогою невеликої модифікцїі методаunapplySeq, з використанням іншої сигнатури метода:

1
def unapplySeq(object: S): Option[(T1, .., Tn-1, Seq[T])]

Як ви можете бачити, unapplySeq також може повертати Option з TupleN, де останній елемент кортежу має бути послідовністю, що містить змінну частину виділених значень. Ця сигнатура методу повинна бути дещо знайомою, тому що вона подібна до однієї з можливих сигнатур методу unapply, що я показував на минулому тижні. 

Ось екстрактор, що використовує це:

1
2
3
4
5
6
7
object Names {
  def unapplySeq(name: String): Option[(String, String, Seq[String])] = {
    val names = name.trim.split(" ")
    if (names.size < 2) None
    else Some((names.last, names.head, names.drop(1).dropRight(1)))
  }
}

Подивіться ближче на повертаємий тип, та на конструкцію Some. Наш метод повертає Option з Tuple3. Цей кортеж стоврений за допомогою синтаксиса Scala дял літералів, просто покладаючи три елементи – останнє ім'я, перше ім'я та послідовність додаткових наданих імен – в парі дужок.

Якщо цей екстрактор використовується в шаблоні, шаблон буде співпадати, якщо щонайменьше останнє ім'я міститься в наданому вхідному рядку. Послідовність додаткових наданих імен створюється, відкидаючи перший та останній елемент з послідовності імен.

Ми можемо використовувати цей екстрактор для реалізації альтернативного метода привітання:

1
2
3
4
def greet(fullName: String) = fullName match {
  case Names(lastName, firstName, _*) => "Good morning, " + firstName + " " + lastName + "!"
  case _ => "Welcome! Please make sure to fill in your name!"
}

Почувайтесь вільним погратись з цім в REPL або на робочому листі.

Підсумок

В цій главі ми навчились, як реалізовати та використовувати екстрактори, що повертають послідовності виділених значень змінної довжини. Екстрактори є досить потужним механізмом. Вони часто можуть  бути повторно використані в гнучкий спосіб, та провадити потужний шлях для розширення типів шаблонів, з якими ви можете робити порівняння.

Ми будемо переглядати екстрактори в навчальному прикладі наприкінці кінця цієї серії. В наступній частині, однак, я надам огляд різних шляхів, яким шаблони можуть застосовуватись в коді Scala – є більше варіантів порівнянь з шаблоном, ніж ви вже бачили до тепер. 

Оновлення, 24.01.2013: Я оновив приклад кода, реалізуючи екстрактор  GivenNames. Дякую Christophe Bliard, що вказав на помилку.

Частина 3. Шаблони повсюди

В перших частинах цієї серії я витратив деякий час, пояснюючи, що насправді відбувається, коли ви деструктуєте примірник кейс класу в шаблоні, та як писати ваші власні екстракторі, дозволяючи вам деструктувати любі типи об'єктів, в любий бажаний спосіб.

Тепер настав час зробити огляд, де шаблони можуть насправді використовуватись в вашому Scala коді, оскільки до тепер ви бачили тільки одну з різноманітних шляхів для використання шаблонів. От де ми є!

Вирази порівняння шаблонів

Одне місце, де можуть з'являтись шаблони, це вирази поірвняння шаблонів. В такій спосіб використання шаблонів має бути дуже вам знайомим після проходження курсу Scala на Coursera, та впродовж цієї серії. Ви маєте неякий виразe, за який слідує ключове слово matchта блок, що може містити любе число випадків. Випадок, в свою чергу, складається з ключового слова case, за яким слідує шаблон, та, опціонально, зліва частина охоронця, плюс блок зправа, що буде виконано, якщо шаблон співпадає.

Ось простий приклад, що використовує шаблон, та охоронця в одному з випадків:

1
2
3
4
5
6
case class Player(name: String, score: Int)

def printMessage(player: Player) = player match {
  case Player(_, score) if score > 100000 => println("Get a job, dude!")
  case Player(name, _) => println("Hey " + name + ", nice to see you again!")
}

Метод printMessage має тип Unit, його єдине призначення полягає в побічному ефекті, наразі в друку повідомлення. Важливо пам'ятати, що ви не маєте використовувати порівняння з шаблоном, як ви це робили з використанням твердження switch в мовах, як Java. Те, що ми тут використовуємо, називається виразом порівняння з шаблоном не даремно. Їх повернуте значення є те, що повертається з блоку, який відповідає до першого співпавшого шаблону.

Звичайно, є гарною ідеєю отримати зиск з цього, оскільки це дозволяє вам розділити дві речі, що насправді не належать одне до одного, роблячи простішим тестування вашого коду, також. Ми також отримали приклад вище, наступним чином:

1
2
3
4
5
def message(player: Player) = player match {
  case Player(_, score) if score > 100000 => "Get a job, dude!"
  case Player(name, _) => "Hey " + name + ", nice to see you again!"
}
def printMessage(player: Player) = println(message(player))

Тепер, ми маємо виділити метод message, що повертає типString. Це, загалом, чиста функція, що повертає вираз порівняння шаблону. Ви можете також зберігти результат такого порівняння, як значення, або, звичайно, присвоїти його змінній.

Шаблони в визначеннях значення

Інше місце, де в Scala може трапитись порівняння з шаблоном, є ліва частина визначення значення (та в визначенні змінної, в цьому значенні, але ми бажаємо написати наш Scala код в функціональному стилі, так що ми не бажаємо бачити багато визначень змінних в цій серії). Давайте вважати, що ми маємо метод, що повертає поточного гравця. Ми будемо використовувати умовну реалізацію, що постійно повертає одного гравця:

1
def currentPlayer(): Player = Player("Daniel", 3500)

Ваше звичайне визначення виглядатиме так:

1
2
val player = currentPlayer()
doSomethingWithTheName(player.name)

Якщо ви знаєте Python, ви, можливо, знайомі з можливістю, відомою як розпакування послідовності. Факт, що ви можете використовувати любі шаблони зліва від визначення значення, або визначення змінної, дозволяє вам писати ваш Scala код в подібному стилі. Ми можемо змінити код вище, та деструктувати наданого поточного гравця , при тому присвоювуючи його до лівої сторони:

1
2
val Player(name, _) = currentPlayer()
doSomethingWithTheName(name)

Ви можете робити це з кожним шаблоном, але, загалом, є гарною ідеєю, щоб переконатись, що ваш шаблон завжди співпадає. Інакше ви станете свідком виключення під час виконання. Наприклад, наступний код
 проблематичним. Методscores повертає список досягнень. В нашому коді нижче, це метод просто повертає пустий список, щоб проілюструвати цю проблему.

1
2
3
def scores: List[Int] = List()
val best :: rest = scores
println("The score of our champion is " + best)

Отакої, ми отримали MatchError. Здається, що наша гра не є успішною, кінець кінцем, не маючи будь-яких рейтингів.

Безпечний, та дуже зручний, спосіб використання шаблонів в цей спосіб є деконструкцію кейс класів, чий тип відомий під час компіляції. Також, коли робимо з кортежами, це робить ваш код значно більш читабельним. Давайте уявімо, що ми маємо функцію, що повертає ім'я гравця, та його бали, в вигляді кортежа, не використовуючи клас Player, що ми використовували до цього:

1
def gameResult(): (String, Int) = ("Daniel", 3500)

Доступ до полів кортежа завжди виглядає дуже незручним:

1
2
val result = gameResult()
println(result._1 + ": " + result._2)

Є безпечним деструктурувати наш кортеж в визначенні типу, коли ми знаємо, що маємо справу з Tuple2:

1
2
val (name, score) = gameResult()
println(name + ": " + score)

Це читається значно краще, чи не так?

Шаблони в for осягненнях

Шаблони також мають дуже впливове місце в for осягненнях. Для початку, for осягнення може також містити визначення значень. Та все, що ви вивчили щодо використання шаблонів в лівій стороні визначень значень, остається вірним для визначень значень в осягненнях. Таким чином, якщо ми маємо колекцію результатів гравців, та бажаємо скласти їх рейтинг, що в нашій грі є тільки набором імен гравців, що перетнули деякий рубіж по очках, ми можемо зробити це в дуже зрозумілий спосіб завдяки осяжності:

1
2
3
4
5
6
7
8
def gameResults(): Seq[(String, Int)] =
  ("Daniel", 3500) :: ("Melissa", 13000) :: ("John", 7000) :: Nil

def hallOfFame = for {
  result <- gameResults()
  (name, score) = result
  if (score > 5000)
} yield name

Результатом є List("Melissa", "John"), оскільки перший гравець на задовільняє умові охоронця.

Це може бути записане навіть більш стисло, оскільки в for осяжності ліва сторона генератора також є шаблоном. Так що, замість спочатку присвоювати кожний результат гри до result, ми можемо напряму деструктувати результат в лівій стороні генератора:

1
2
3
4
def hallOfFame = for {
  (name, score) <- gameResults()
  if (score > 5000)
} yield name

В цьому прикладі шаблон (name, score) завжди співпадає, так що, якщо б не було твердження охоронця, if (score > 5000), for осяжність була б еквівалентною до простого відображення з кортежів на імена гравців, без жодної фільтрації.

Важливо знати, що шаблони в лівій стороні генератора можуть все ще бути використані для цілей фільтрації – якщо шаблон в лівій частині не співпадає, відповідний елемент буде відсіяно.

Щоб продемонструвати це, давайте уявімо, що ми маємо послідовність списків, та ми бажаємо повернути розміри для непорожніх списків. Це означає, що ми маємо відфільтрувати всі непорожні списки, та потім повернути розміри тих, що залишились. Ось одне з рішень:

1
2
3
4
5
val lists = List(1, 2, 3) :: List.empty :: List(5, 3) :: Nil

for {
  list @ head :: _ <- lists
} yield list.size

Шаблон зліва генератора не співпадає з пустими списками. Це не викликає  MatchError, але призводить до виключення пустих списків. Таким чином, ми отримаємо List(3, 2).

Шабони та for осяжності є дуже природним та потужним поєднанням, та якщо ви проробите зі Scala деякий час, ви побачите, що ви використовуєти їх досить багато.

Анонімні функції

Нарешті, шаблони можуть використовуватись для визначення анонімних функцій. Якщо ви будь-коли використовували блок catch, щоб мати справу з виключеннями  Scala, тоді ви використовували цю можливість. Функції порівняння з шаблоном є привідом для окремого блог посту, оскільки є багато чого, що варто про них сказати. Таким чином, я уникну заглиблення в це використання шабонів, в цьому розділі, та замість цього залишаю вам обіцянку повернутись до цього в наступній частині цієї серії.

Оновлення: Виправдено помилку в очікуваному результаті hallOfFame for осяжності. Дякую Rajiv що вказав на це.

Частина 4: Анонімні функції порівняння шаблонів

В попередній частині я надав огляд різних шляхів, як порівняння шаблонів може бути застосоване в Scala, завершуючи коротким спогадом про анонімні функції, як інше місце, де шаблони можуть знайти своє застосування. В цьому пості ми збираємось покалсти уважний погляд на можливості, що відкриває визначення анонімних функцій таким чином.

Якщо ви брали цчасть в курсі Scala на Coursera, або кодували на Scala деякий час, ви, вірогідно, писали анонімні функції на регулярній основі. Наприклад, беручи список наз пісень, що ви бажаєте перетворити на нижній реєстр для індексу пошуку, ви, можливо, визначали анонімну функцію, що ви передавали до методу map, таким чином:

1
2
val songTitles = List("The White Hare", "Childe the Hunter", "Take no Rogues")
songTitles.map(t => t.toLowerCase)

Або, якщо ви бажаєте ще коротше, ви можете нормалізувати функцію, таким чином, з використанням синтаксису заміщувача making use of Scala:

1
songTitles.map(_.toLowerCase)

Доки все гаразд. Однак давайте подивимось, як цей синтаксис виступає в дещо іншому прикладі: ми маємо послідовність пар, кожна представляє слово та його частоту в деякому тексті. Наша ціль відфільтрувати ці пари, чия частота нижче або виде деякого значення, та потім повернути залишок слів, без їх відповідних частот. нам треба написати функціюwordsWithoutOutliers(wordFrequencies: Seq[(String, Int)]): Seq[String].

Наше початкове рішення використовує методи filter та map, передаючи анонімні функції до них, з використанням знайомого синтаксису:

1
2
3
4
5
val wordFrequencies = ("habitual", 6) :: ("and", 56) :: ("consuetudinary", 2) ::
  ("additionally", 27) :: ("homely", 5) :: ("society", 13) :: Nil
def wordsWithoutOutliers(wordFrequencies: Seq[(String, Int)]): Seq[String] =
  wordFrequencies.filter(wf => wf._2 > 3 && wf._2 < 25).map(_._1)
wordsWithoutOutliers(wordFrequencies) // List("habitual", "homely", "society")

Це рішення має декілька проблем. Перша є тільки естетичною – доступ до полів кортежу є огидним, як на мене. Якщо ми бажаємо тільки деструктувати пару, ми можемо зробити цей код трохи більш приємним, та, можливо, більше читабельним.

На щастя,  Scala провадить альтернативний шлях написання анонімних функції: анонімна функція порівняння з шаблоном є анонімною функцією, що визначена як блок, що складається з послідовності випадків, заточений, як завжди, в фігурні дужки, але без ключового слова match перед блоком. Давайте перепишемо нашу функцію з застосуванням цієї нотації:

1
2
def wordsWithoutOutliers(wordFrequencies: Seq[(String, Int)]): Seq[String] =
  wordFrequencies.filter { case (_, f) => f > 3 && f < 25 } map { case (w, _) => w }

В цьому прикладі ми використовували тільки один випадок в кожній з наших анонімних функцій, оскільки ми знаємо, що цей випадок завжди співпадатиме – ми просто декопонували структуру даних, чий тип вже відомий під час компіляції, так що тут не може відбутись нічого поганого. Це дуже загальний спосіб використання порівняння шаблону з анонімними функціями.

Якщо ви будете намагатись присвоїти ці анонімні функції до значень, ви побачите, що вони матимуть очікуваний тип:

1
2
val predicate: ((String, Int)) => Boolean = { case (_, f) => f > 3 && f < 25 }
val transformFn: ((String, Int)) => String = { case (w, _) => w }

Будь ласка, зауважте, що вам треба вказати тип значення, компілятор Scala не може вивести його з анонімних функцій порівняння з шаблоном.

Звичайно, ніщо не заважає вам визначати більш складні послідовності випадків. Однак, якщо ви визначаєте анонімну функцію таким чином, та бажаєте передати ії в якусь іншу функцію, як в нашому прикладі,ви маєте переконатись, що для всіх можливих вводів співпадає з одним з ваших випадків, так, щоб ця ваша анонімна функція завжди повертала значення. Інакше ви ризикуєте отримати MatchError під час виконання.

Часткові функції

Іноді, однак, функція, що визначена тільки для специфічних значень вводу - це саме те, що вам потрібне. Фактично, таа функція може допомогти нам подолати іншу проблему, що все ще не вирішена, з нашою поточною реалізацією функціїwordsWithoutOutliers: ми спершу фільтруємо надану послідовність, та потім відображуємо елементи, що залишились. Якщо ми можемо утрусити це до рішення, що ітерує по послідовності тільки один раз, це не тільки зменшить цикли CPU, але також зробить код меньшим, та, нарешті, більш зрозумілим.

Якщо ви передивлялись Scala API для колекцій, ви могли помітити метод з назвоюcollect, що, для Seq[A]має наступну сигнатуру, :

1
def collect[B](pf: PartialFunction[A, B])

Цей метод повертає нову послідовність, застосовуючи надану часткову функццію до всіх елементів – часткова функція обоє, фільтрує та відображує послідовність.

Так що таке часткова функція? Коротко, це унарна функція, що, як відомо, визначена тільки для певних вхідних значень, та дозволяє клієнтам перевіряти, чи вона визначена для специфічного вхідного значення.

З цього боку, трейт PartialFunction провадить метод isDefinedAt. Фактично, типPartialFunction[-A, +B] поширює тип(A) => B (що також може бути записано як Function1[A, B]), та анонімна функція порівняння шаблону є завжди типуPartialFunction.

Через цю ієрархію наслідування, передача анонімної функції порівняння з шаблоном до метода, що очікуєFunction1, як  map або filter, є досить гарним, доки ця функція визначена для всіх вхідних значень, тобто завжди існує співпадаючий випадок.

Методcollect, однак, особливо очікує PartialFunction[A, B], що може бути визначена для всіх вхідних значень, та знає, як саме мати справу з цім випадком. Для кожного елементу в послідовності вона, спершу, перевіряє, чи часткова функція визначена для нього, викликаючи isDefinedAt на частково визначеній функції. Якщо це повертає false, елемент ігнорується. В іншому випадку, результат застосування часткової функції до елементу додається до результуючої послідовності.

Давайте спочатку визначимо часткову функцію, що ми бажаємо використати для рефакторингу нашої функції  wordsWithoutOutliers, щоб задіятиcollect:

1
2
3
val pf: PartialFunction[(String, Int), String] = {
  case (word, freq) if freq > 3 && freq < 25 => word
}

Ми додали твердження захисника до нашого випадку, так що ця функція не буде визначена до для пар слово/частота, чия частота не в потрібному диапазоні.

Замість використання синтаксису для анонімних фунцій порівняння шаблонів, ми можемо визначити цю часткову функцію, явно розширивши трейтPartialFunction:

1
2
3
4
5
6
7
8
9
val pf = new PartialFunction[(String, Int), String] {
  def apply(wordFrequency: (String, Int)) = wordFrequency match {
    case (word, freq) if freq > 3 && freq < 25 => word
  }
  def isDefinedAt(wordFrequency: (String, Int)) = wordFrequency match {
    case (word, freq) if freq > 3 && freq < 25 => true
    case _ => false
  }
}

Однак, зазвичай, ви побажаєте використовувати значно більш стислий синтаксис анонімних функцій.

Тепер, якщо ми передали нашу часткову функцію до метода map, це буде досить гарно компілюватись, але призведе доMatchError під час використання, оскільки наша часткова функція не визначена для всіх можливих вхідних значень, дякуючи до доданих тверджень захисників:

1
  wordFrequencies.map(pf) // will throw a MatchError

Однак ми можемо передати цю часткову функцію до метода collect, та він буде фільтрувати та відображувати послідовність, як очікується:

1
  wordFrequencies.collect(pf) // List("habitual", "homely", "society")

Результат цього такий же самий , як той, що дає наша поточна реалізація wordsWithoutOutliers, коли ми передаємо нашу умовну послідовність  wordFrequenciesдо неї. Таким чином, давайте перепишемо цю функцію:

1
2
def wordsWithoutOutliers(wordFrequencies: Seq[(String, Int)]): Seq[String] =
  wordFrequencies.collect { case (word, freq) if freq > 3 && freq < 25 => word }

Часткові функції мають деякі інші, дуже важливі функції. Наприклад, вони провадять умови для сціплення, дозволяючи милу функціональну альтернативу ланцюгам шалонів відповідальності, відомим з об'єкт-орієнтовного програмування. Це, однак, буде приводом для наступного посту в серії, де я збираюсь розглянути питання функціональної здатності до композиції.

Часткова функціональність є також накутним елементом багатьох бібліотек Scala та API. Наприклад, спосіб, яким актори Akka обробляють надіслані ним повідомлення, визначені в термінах часткових функцій. Таким чином, це досить важливо знати та розуміти цю концепцію.

Підсумок

І цій частині ми дослідили альтернативний шлях визначення анонімних функцій,  зокрема для послідовності випадків, що відкриває деякі милі можливості деструкції, в дещо компактному вигляді. Більше того, ми занурились в тему часткових функцій, демонструючи їх корисність в аспекті простого випадку.

В наступній главі я маю намір копати глибше в вездесущий тип Option, пояснюючи причини його існування, та його використання найкращим чином.

Будь ласка, дайте мені знати, якщо ви маєте жодні запитання або дещо. Чи є деякі теми, що ви бажаєте побачити в наступних статтях?

Частина 5: Тип Option

На протязі останніх тижнів ми просувались далі, та розлянули більшість підгрунтя, що відноситься до досить складних матерій, зокрема порівняння з шаблонами та екстракторів. Час вповільнитись, та поглянути на більш фундаментальні ідеосинкразичні речі Scala: тип Option.

Якщо ви брали курс Scala на Coursera, ви вже отримали коротке введення в цей тип, та бачили його в дії в MapAPI. В цій серії ми також використовували його, коли реалізували свої власні екстрактори. 

Але все ще залишилось багато, що треба прояснити про нього. Ви, можливо, дивувались, що це все за галас кругом, що такого значно кращого щодо опцій, ніж інших шляхів представлення відсутності значення. Ви також, можливо, не в курсі, як насправді робити з типом Option в вашому власному коді. Ціль цієї частини серії позбутися всіх ціх знаків запитань, та навчитись всьому, що насправді треба знати щодоOption,  в якості завзятого новачка Scala.

Базова ідея

Якщо ви взагалі робили з Java в минулому, дуже вірогідно, що ви мали в деякий момент NullPointerException (інші мови будуть викликати подібні помилки в такому ж випадку). Можливо це трапляється, оскільки деякий метод повертає null, коли ви не очікували його, і, таким чином, не розглядали таку можливість в клієнтському коді. Значення null часто невірно використовується для представлення відсутнього опціонального значення.

Деякі мови трактують значення null в осообливий спосіб, або дозволяють вам безпечно робити зі значеннями, що можуть бути null. Наприклад, Groovy має null-безпечний оператор для доступу до властивостей, так що foo?.bar?.baz не буде викликати виключення, якщо або foo, або його властивість bar є null, замість цього напряму повертаючи null. Однак ви схибите, якщо ви забудете використати цей оператор, та ніщо вас не змушує до цього.

Clojure загалом трактує своє значення nil як пусту річ, тобто як пустий список, якщо доступ іде як до списка, або як пусту мапу, якщо доступ іде як до мапи. Це означає, що значення nil підіймається вгору по ієрархії викликів. Дуже часто все гаразд, але іноді це призводить до виключення значно вище в ієрархії викликів, де деякий шматок кода не є nil-дружнім в кінці кінців.

Scala намагається вирішити проблему позбавлення від значень null, разом провадячи свій власний тип для представлення опціональних значень, тобто значень, що можуть бути присутніми, або ні: трейт Option[A].

Option[A] є контейнером для опціонального значення типу A. Якщо значення типу  A присутнєOption[A] є примірником Some[A], що містить присутнє значення типу  A. Якщо значення відсутнє, Option[A] є об'єктом None.

Затверджуючи, що значення може бути, або не бути присутнім, на рівні типу, ви, та всі інши розробники, що роблять з вашим кодом, змушені компілятором мати справу з ціма можливостями. Немає способу, коли ви випадково будете покладатись на присутність значення, що насправді опціональне.

Option є обов'язковим! Не використовуйте null для позначення, що опціональне значення відсутнє.

Створення опції

Звичайно, ви можете просто створити Option[A] для існуючого значення, напряму уособивши кейс клас Some:

1
val greeting: Option[String] = Some("Hello world")

Або, якщо ви знаєте, що значення відсутнє, ви просто присвоюєте або повертаєте об'єкт None:

1
val greeting: Option[String] = None

Однак, час від часу вам треба взаємодіяти з бібліотеками Java, або кодом на других мовах JVM, що щасливо використовують null для позначення відсутніх значень. З цієї причини об'єкт-компанйон Option провадить метод-фабрику, що створює None, якщо параметр є null, інакше параметр огортається в Some:

1
2
val absentGreeting: Option[String] = Option(null) // absentGreeting буде None
val presentGreeting: Option[String] = Option("Hello!") // presentGreeting буде Some("Hello!")

Робота з опціональними значеннями

Це все мило, але як, насправді, робити з опціональними значеннями? Прийшов час для прикладу. Давайте зробимо щось нудне, так, щоб зконцентруватись на важливих речах.

Уявімо, що ви робите на одному з тих жахливих стартапів , і однією з перших речей, щ вам треба, є реалізація репозитарію користувачів. Нам треба бути в змозі знайти користувача за його унікальним id. Іноді запити приходять з деякими фіктивними id. Це закликає до типу повернення Option[User] для нашого метода пошуку. Умовна реалізація нашого репозитарію користувачів може виглядати так:

1
2
3
4
5
6
7
8
9
10
11
12
13
case class User(
  id: Int,
  firstName: String,
  lastName: String,
  age: Int,
  gender: Option[String])

object UserRepository {
  private val users = Map(1 -> User(1, "John", "Doe", 32, Some("male")),
                          2 -> User(2, "Johanna", "Doe", 30, None))
  def findById(id: Int): Option[User] = users.get(id)
  def findAll = users.values
}

Тепер, якщо ви отримали примірник Option[User] від UserRepository, та потребуєте зробити щось з ним, як це зробити?

Одним шляхом може бути перевірка, чи значення присутнє силами метода isDefinedвашої опції, та, якщо це так, отримати значення через метод get:

1
2
3
4
val user1 = UserRepository.findById(1)
if (user1.isDefined) {
  println(user1.get.firstName)
} // will print "John"

Це дуже подібно то того, як робить тип Optional бібліотеки Guava в Java. Якщо ви вважаєте, що це довге, та очікуєте дещо більш елегантне від Scala, ви на вірному шляху. Більш важливо, якщо ви використовуєте get, ви можете забути про перевірку isDefined перед цім, що призведе до виключення під час виконання, так що ви не отримаєте значно більше, ніж від використання null.

Ви повинні триматись подалі від цього шляху доступу до опцій, тільки як це можливо!

Провадження значення по замовчанню

Дуже часто ви бажаєте робити з вдкатом, або значенням по замовчанню, в випадку опціонального значення, що відсутнє. Це випадок використання є дуже гарно виражений методомgetOrElse, визначений для Option:

1
2
val user = User(2, "Johanna", "Doe", 30, None)
println("Gender: " + user.gender.getOrElse("not specified")) // will print "not specified"

Будь ласка зауважте, що значення по замовчанню, яке ви можете вказати як параметр до методу getOrElse, є параметром за ім'ям, що означає, що він обчислюється тільки якщо опція, на якій ви викликаєте getOrElse насправді None. Таким чином, немає потреби турбуватись, якщо створення значення по замовчанню є коштовним з однієї або іншої причини – це буде траплятись, тільки якщо значення по замовчанню буде насправді потрібним.

Порівняння з шаблоном

Some є кейс класом, так що чудово можливо використовувати його в шаблоні, буде це в звичайному виразі порівняння з шаблоном, або якомусь іншому дозволеному місці. Давайте перепишемо приклад вище, з використанням порівняння з шаблоном:

1
2
3
4
5
val user = User(2, "Johanna", "Doe", 30, None)
user.gender match {
  case Some(gender) => println("Gender: " + gender)
  case None => println("Gender: not specified")
}

Або, якщо ви бажаєте виключити дублікат твердження println, та використати факт, що ми робитмо з виразом порявняння з шаблоном:

1
2
3
4
5
6
val user = User(2, "Johanna", "Doe", 30, None)
val gender = user.gender match {
  case Some(gender) => gender
  case None => "not specified"
}
println("Gender: " + gender)

Можливо ви помітили, що порівняння з шаблоном примірника Optionдосить балакуче, через що зазвичай не є ідиоматичним обробляти опції в цей спосіб. Таким чином, навіть якщо ви всі захоплені щодо порівняння шаблонів, спробуйте використовувати альтернативи при роботі з опціями.

Є один досить елегантний шлях використання шаблонів з опціями, що ви навчитесь коли дійдете до розділу нижче про осяжності.

Опції можна розглядати як колекції

Доки ви не бачили багато елегантних ідеоматичних шляхів роботи з опціями. Тепер ми займемось цім.

Я завжди казав, що Option[A] є контейнером для значення типу A. Більш точно, ви можете думати про це, як про різновид колекції – деяка особлива сніжинка колекції, що містить або нуль елементів, або точно один елемент типуA. Це дуже потужна ідея!

Навіть, думаючи на рівні типу, якщо Option не є типом колекції в Scala, опції ідуть з усіма благами, що ви цінуєте в колекціях Scala, як List, Set,таке інше – та якщо вам дійно це треба, ви навіт можете трансформувати опції, наприклад, в List.

Так що це дозволяє нам робити?

Виконання побічних ефектів за наявності значення

Якщо ви бажаєте виконати деякий побічний ефект за наявності опціонального значення, метод foreach, відомий з колекцій Scala стане вам у пригоді:

1
UserRepository.findById(2).foreach(user => println(user.firstName)) // prints "Johanna"

Функція, передана до foreach, буде викликана один раз, якщо Option є Some, або жодного разу, якщо це None.

Відображення (мапи) опцій

Дійсно гарна річ щодо опцій в якості колекцій в тому, що ви можете робити з ними в дуже функціональний спосіб, та цей спосіб співпадає з тим, як виробите зі списками, наборами, etc.

Так само, як ви можете відобразити List[A] на List[B], ви можете відобразити Option[A]на Option[B]. Це означає, що якщо ваш примірник Option[A] є визначеним, тобто якщо це Some[A], результат є Some[B], інакше це None.

Якщо ви порівняєте Option з List, None еквівалентно пустому списку : коли ви відображуєте List[A], ви отримуєте пустий List[B], та коли ви відображуєте Option[A] що є None, ви отримуєте Option[B] що є None.

Давайте отримаємо рік опціонального користувача:

1
val age = UserRepository.findById(1).map(_.age) // age є Some(32)

flatMap та опції

Давайте зробимо те ж саме зі статтю:

1
val gender = UserRepository.findById(1).map(_.gender) // gender є Option[Option[String]]

Тип отриманого результату gender є Option[Option[String]]Чому це?

Подумайте про це таким чином: ви маєте контейнер Option для User, та всередині цього контейнера ви відображуєте примірник User на Option[String], оскікльки це тип даих для властивості gender класу User.

Ці вкладені опції є надокучливими? Ось чому, так само як всі колекції, Option також провадить метод flatMap. Точно як ви можете flatMap List[List[A]] до List[B], ви можете зробити те саме для Option[Option[A]]:

1
2
3
val gender1 = UserRepository.findById(1).flatMap(_.gender) // gender є Some("male")
val gender2 = UserRepository.findById(2).flatMap(_.gender) // gender є None
val gender3 = UserRepository.findById(3).flatMap(_.gender) // gender є None

Тип результата тепер Option[String]. Якщо користувач визначений, та його стать також визначена, ми отримуємо згладжений Some. Якщо або користувач, або стать не визначені, ми отримуємо None.

Щоб зрозуміти, як це робить, давайте подивимось, що відбувається при пласкому відображенні списка списків рядків, завжди маючи на думці, що Option є також колекцією, так само як List:

1
2
3
4
5
6
val names: List[List[String]] =
  List(List("John", "Johanna", "Daniel"), List(), List("Doe", "Westheide"))
names.map(_.map(_.toUpperCase))
// результат List(List("JOHN", "JOHANNA", "DANIEL"), List(), List("DOE", "WESTHEIDE"))
names.flatMap(_.map(_.toUpperCase))
// результат List("JOHN", "JOHANNA", "DANIEL", "DOE", "WESTHEIDE")

Якщо ми використовуємо flatMap, ми відображуємо елементи внутнішніх списків, та конвертуємо все в один плаский список рядків. Зрозуміло, нічого не залишиться від порожніх внутрішніх списків.

Щоб повернутись до типу Option, уявіть, що трапиться, якщо ви відобразите список опцій рядків:

1
2
3
val names: List[Option[String]] = List(Some("Johanna"), None, Some("Daniel"))
names.map(_.map(_.toUpperCase)) // List(Some("JOHANNA"), None, Some("DANIEL"))
names.flatMap(xs => xs.map(_.toUpperCase)) // List("JOHANNA", "DANIEL")

Якщо ви тільки відобразите список обцій, результат зостанеться  List[Option[String]]. З використанням  flatMap, всі елементи внутрішніх колекцій покладаються в плаский список: оидн елемент з кожного Some[String]в оригінальному списку розгорнуті, та покладені в результуючий список, тоді як любе значення None в оригінальному списку не містить жодного елементу, що має бути розгорнутий. Таким чином, значення None ефективно відфільтровуються.

З таким знанням, давайте подивимось знову, що робить flatMap на типі Option.

Фільтрація опцій

Ви можете фільтрувати опції, точно як ви фільтруєте список. Якщо примірник Option[A] визначений, тобто якщо це Some[A], та предикат, переданий до filter повертає true для огорнутого значення типу A, повертається примірник Some. Якщо примірник Option вже None, або предикат повертаєfalse для значення в  Some, результат None:

1
2
3
UserRepository.findById(1).filter(_.age > 30) // Some(user), бо age > 30
UserRepository.findById(2).filter(_.age > 30) // None, оскільки age є <= 30
UserRepository.findById(3).filter(_.age > 30) // None, бо user вже None

For осягнення

Тепер, коли ми знаємо, що Option може трактуватись як колекція, та провадить  map, flatMap, filter та інші методи, що ви знаєте з колекцій, ви, можливо, вже підозрюєте, що опції можуть бути використані в for осягненнях. Часто це найбільш читабельний шлях роботи з опціями, особилво якщо ви маєте зціпити багато map, flatMap та filter викликів. Якщо це тільки поодинокий map, це часто може бути переважним, бо це тільки трохи менш балакуче.

Якщо ви бажаєте отримати стать для окремого користувача, ми можемо застосувати наступну осяжність:

1
2
3
4
for {
  user <- UserRepository.findById(1)
  gender <- user.gender
} yield gender // результат є Some("male")

Як ви, можливо, знаєте з роботи зі списками, це еквівалентно до якладоних викликів flatMap. Якщо UserRepository вже повертає None, або Gender є None, результат осяжності є None. Для користувача в цьому прикладі визначена стать, так що вона буде повернута в Some.

Якщо ми бажаємо отримати статі для всіх користувачів, для яких ми її вказали, ми ітеруємо по всіх користувачах, та для кожного з них отримуємо стать, якщо вона визначена:

1
2
3
4
for {
  user <- UserRepository.findAll
  gender <- user.gender
} yield gender

Оскільки ми ефективно сплющили відображення, результуючий тип є List[String], та результуючий список є List("male"), оскільки gender визначений тільки для першого користувача.

Використання в лівій частині генератора

Можливо, ви пам'ятаєте з третьої частиницієї серій, що ліва частина генератора в осяжності є шаблоном. Це означає, що ви можете мати опції з шаблонами в ваших осяжностях.

Ми можемо переписати попередній приклад наступним чином:

1
2
3
for {
  User(_, _, _, _, Some(gender)) <- UserRepository.findAll
} yield gender

Використання шаблону Some в лівій стороні генератора має ефект видалення всіх елементів з результата колекції, для яких відповідне значення є None.

Сціплення опцій

Опції також можуть бути зціплені, що є трохи подібним до зціплення часткових функцій. Щоб зробити це, ви викликаєте orElse на примірнику Option, та передати інший примірник Option я параметр за ім'ям. Якщо останній є None, orElse повертає опцію, що передана йому, інакше він повертає той, на якому він викликаний.

Гарний приклад застосування для цього є пошук ресурсів, коли ви маєте декілька локацій, де його можна знайти, та порядок переваг. В нашому прикладі ми схильні шукати ресурс в каталозі конфігурації, так що ми викликаємо orElse на ньому, передаючи альтернативну опцію:

1
2
3
4
case class Resource(content: String)
val resourceFromConfigDir: Option[Resource] = None
val resourceFromClasspath: Option[Resource] = Some(Resource("I was found on the classpath"))
val resource = resourceFromConfigDir orElse resourceFromClasspath

Це загалом гарно підходить, якщо ви бажаєте зціпити більше ніж дві опції – якщо ви просто бажаєте запровадити значення по замовчанню, в випадку відсутності наданої опції, метод getOrElse може бути кращою ідеєю.

Підсумок

В цій главі я намагався надати все, що вам треба знати про тип Option, щоб використовувати його з користю, щоб розуміти код інших розробників Scala, та писати більш читаємий, функціональний код. Найбільш важливий висновок, що можна отримати з цього посту, полягає в тому, що є дуже проста базова ідея, загальна для списків, наборів, мап, опцій, та, як ви побачите в майбутніх постах, для інших типів даних, та існує одноманітний шлях використання ціх типів, що разом, елегантний та потужний.

В наступній частині цієї серії я збираюсь мати справу з ідиоматичною, функціональною обробкою помилок в Scala.

Частина 6: Обробка помилок за допомогою Try

Коли ми тільки граємось з новою мовою, ви можете просто пройти повз факт, що дещо може пійти не так. Але як тільки ви захочете створити дещо серьйозне, ви не зможете більше втекти від обробки помилок та виключень у вашому коді. Важливість того, наскільки гарно мова підтримує вас для роботи над помилками часто недооцінюють, з тієї чи іншої причини.

Як з'ясовується, Scala досить гарно позиційована, коли річ доходить до обробки помилок. В цій главі я збираюсь презентувати підхід Scala до обробки помилок, базуючись на типі  Try, та обгрунтування цього. Я використовую можливості, введені в Scala 2.10, та портовані назад в Scala 2.9.3, так що переконайтесь, що ваша версія Scala в SBT є 2.9.3 або пізніша.

Підняння та перехоплення виключень

Перед переходом прямо до ідеоматичного підходу Scala до обробки помилок, давайте спершу подивимось на підхід, що більше скидається на те, що використовується для роботи з помилковими умовами, якщо ви прийшли з мов, як Java або Ruby. Як і ці мови, Scala дозволяє вам підіймати виключення:

1
2
3
4
5
6
7
case class Customer(age: Int)
class Cigarettes
case class UnderAgeException(message: String) extends Exception(message)
def buyCigarettes(customer: Customer): Cigarettes =
  if (customer.age < 16)
    throw UnderAgeException(s"Customer must be older than 16 but was ${customer.age}")
  else new Cigarettes

Підняті виключення можуть бути перехоплені, та оброблені дуже подібно до Java, хоча і з використанням часткової функції для визначення виключень, з якими ми бажаємо мати справи. Також, в Scala try/catch є виразом, так що наступний код повертає повідомлення з виключення:

1
2
3
4
5
6
7
val youngCustomer = Customer(15)
try {
  buyCigarettes(youngCustomer)
  "Yo, here are your cancer sticks! Happy smokin'!"
} catch {
    case UnderAgeException(msg) => msg
}

Обробка помилок, функціональний шлях

Тепер, маючи цей різновид коду обробки виключень по всьому вашому коду, може дуже швидко стати бридким, та не іде гарно в ногу з функціональним програмуванням. Це також досить погане рішення для застосувань з масовою конкурентністю. Наприклад, якщо вам треба мати справу з виключенням, викликаним в Actor, що виконується в іншому потоці, ви, очевидно, не можете зробити це, перехоплюючи виключення – ви будете бажати можливість отримувати повідомлення, що вказує на умови помилки.

Таким чином, в Scala зазвичай більш бажано вказати на виникнення помилки, повертаючи відповідне значення від вашої функції.

Не турбуйтесь, ми не збираємось повертатись до обробки помилок в стилі C, використовуючи коди помилок, що ми повинні перевіряти за домовленістю. Скоріше в  Scala ми використовуємо специфічні типи, що представляють обчислення, які можуть призвести до виключень.

В цій статті ми познайомимось з типом Try, що був введений в Scala 2.10, та пізніше портований назад до Scala 2.9.3. Є також інший тип, Either, який, навіть після появи Try, все ще може бути корисним, хоча і є більш загальним.

Семантика Try

Семантику Try краще пояснити в порівнянні з типом Option, що був темою попередньої частини цієї серії.

Коли Option[A] є контейнером для типу A, що може бути присутнім, або ні, Try[A]представляє обчислення, що може результувати в значенні типу A в випадку успіху, або в деякому Throwable, якщо дещо пішло не так. Примірники такого контейнерного типу можуть бути просто передані далі між конкурентно обчислюваними частинами вашого застосування.

Є два різні типи Try: якщо екземпляр Try[A]представляє успішне обчислення, він є примірником Success[A], просто огортаючий значення типи A. Якщо, з іншого боку, він представляє обчислення, де виникла помилка, це примірник Failure[A], огортаючи Throwable, тобто виключення, або іншу помилку.

Якщо ми знаємо, що обчислення може завершитись з помилкою, ми просто можемо використати Try[A] як тип повернення для цієї функції. Це робить можливість явною, та змушує клієнтів наших функцій вирішувати можливість помилки, так чи інакше.

Наприклад, давайте уявимо, що ми бажаємо написати простий завантажувач веб сторінок. Користувач буде в змозі ввести URL веб сторінки, що треба отримати. одна частина вашого застосування буде функцією, що розбиратиме введений  URL, та створювати  java.net.URL з нього:

1
2
3
import scala.util.Try
import java.net.URL
def parseURL(url: String): Try[URL] = Try(new URL(url))

Як ви можете бачити, вона повертає значення типу Try[URL]. Якщо наданий url є синтаксично коректним, це буде Success[URL]. Якщо, однак,  конструктор URL викликає MalformedURLException, це буде Failure[URL].

Щоб досягти цього ми викорстовуємо метод-фабрику apply на об'єкті-компанйоні Try. Цей метод очікує параметр за-ім'ям типу A(тут URL). Для нашого прикладу це означає, що new URL(url) виконується зсередини метода apply об'єкта Try . В цьому методі перехоплюються нефатальні виключення, повертаючи Failure, що містить відповідне виключення.

Таким чином, parseURL("http://danielwestheide.com") буде давати Success[URL]що містить створений URL, тоді як parseURL("garbage") дасть Failure[URL]з  MalformedURLException.

Робота зі значеннями Try

Робота з примірниками Try насправді дуже подібне до роботи зі значеннями Option, так що ви не побачите тут багато сюрпризів.

Ви можете перевірити, чи Try успішне, викликаючи isSuccess на ньому, та потім умовно отримати огорнуте значення, викликаючи get на ньому. Але вірте мені, не багато ситуацій, коли вам знадобиться робити це.

Також є можливим використати getOrElse для передачі значення по замовчанню, якщо Try є Failure:

1
val url = parseURL(Console.readLine("URL: ")) getOrElse new URL("http://duckduckgo.com")

Якщо наданий URL невірно сконфігурований, ми використовуємо URL DuckDuckGo як відкат.

Ланцюжки операцій

Одна з найбільш важливих характеристик типу Try в тому, що, як і Option, він підтримує всі високорівневі методи, що ви знаєте з інших типів колекцій. Як ви побачите в наступних прикладах, це дозволяє вам сціплювати операції зі значеннями Try, та перехоплювати можливі виключення, та все це в дуже прозорій манері. 

Мепінг та плаский мепінг

Мепінг (відображення) Try[A], що  є Success[A], до Try[B], повертає Success[B]. З іншого боку, якщо це Failure[A], результат Try[B] буде Failure[B], що міститиме те ж виключення, що і Failure[A]:

1
2
3
4
parseURL("http://danielwestheide.com").map(_.getProtocol)
// повертає Success("http")
parseURL("garbage").map(_.getProtocol)
// повертає Failure(java.net.MalformedURLException: no protocol: garbage)

Якщо ви зціпите декілька операцій map, це призведе до вкладеної структури Try, яка, звичайно, не є тим, що вам потрібно. Розгляньте цей метод, що повертає вхідний потік для наданого URL:

1
2
3
4
import java.io.InputStream
def inputStreamForURL(url: String): Try[Try[Try[InputStream]]] = parseURL(url).map { u =>
  Try(u.openConnection()).map(conn => Try(conn.getInputStream))
}

Оскільки анонімна функції, передані до двох викликів map кожний повертає Try, загальний тип результата Try[Try[Try[InputStream]]].

Тут вам допоможе факт присутності метода flatMap з Try. Метод flatMap на  Try[A] очікує функцію, що отримує A, та повертає Try[B]. Якщо наш примірник  Try[A] вже Failure[A], цей збій буде повернутий як Failure[B], просто передаючи огорнуте виключення далі по ланцюжку. Якщо наш Try[A] є Success[A], flatMap розпаковує значення A в ньому, та відображує його на Try[B], передаючи це значення до функції мепінгу.

Це означає, що ви загалом можете створити конвеєр операцій, що потребує значення, передані через примірники Success, зціплюючи довільне число викликів методу  flatMap. Любе виключення, що трапляється на шляху, огортається в Failure, що значить, що кінцевий результат ланцюжка операцій також буде Failure.

Давайте перепишемо метод inputStreamForURL з попереднього прикладу, на цей час з  flatMap:

1
2
3
def inputStreamForURL(url: String): Try[InputStream] = parseURL(url).flatMap { u =>
  Try(u.openConnection()).flatMap(conn => Try(conn.getInputStream))
}

Тепер ми отримали Try[InputStream], що може бути Failure, що огортає виключення з любого зі стадій, в якому він був викликаний, або Success, що напряму огортає  InputStream, фінальний результат нашого ланцюжка операцій.

Фільтри та foreach

Звичайно, ви також можете фільтрувати Try або викликати foreach на ньому. Обоє роблять саме так, як ви очікуєте після ознайомлення з Option.

Метод filter повертає Failure, якщо Try, на якоми ви його викликаєте, вже  Failure, або якщо предикот, на якому ви його викликаєте, повертає false (в якому випадку огорнуте виключення є NoSuchElementException). Якщо Try, на якому ви викликаєте, є Success, та предикат повертає true, цей примірник Succcess повертається незмінним:

1
2
3
def parseHttpURL(url: String) = parseURL(url).filter(_.getProtocol == "http")
parseHttpURL("http://apache.openmirror.de") // повертає Success[URL]
parseHttpURL("ftp://mirror.netcologne.de/apache.org") // повертає Failure[URL]

Функція, передана до foreach,  виконується тільки якщо Try є Success, що дозволяє  вам виконувати побічні ефекти. Функція, що передана до foreach, в цьому випадку виконується рівно один раз, будучи переданою як значення, огорнуте в Success:

1
parseHttpURL("http://danielwestheide.com").foreach(println)

For осяжності

Підтримка flatMap, map та filter означає, що ви можете також використовувати for осяжності, щоб зціпити операції примірників Try. Звичайно, це приводить до більш читабельного коду. Щоб продемонструвати це, давайте реалізуємо метод, що повертає вміст веб сторінки з даним URL, з використанням осяжностей.

1
2
3
4
5
6
7
8
import scala.io.Source
def getURLContent(url: String): Try[Iterator[String]] =
  for {
    url <- parseURL(url)
    connection <- Try(url.openConnection())
    is <- Try(connection.getInputStream)
    source = Source.fromInputStream(is)
  } yield source.getLines()

Є три місця, де речі можуть пійти не так, всі з них конвертовані використанням типу Try. Перше, вже реалізований метод  parseURL повертає Try[URL]. Тільки якщо це Success[URL], ми будемо намагатись відкрити з'єднання та створювати новий вхідний потік для нього. Якщо відкриття з'єднання та створення вхідного потоку буде успішним, ми продовжимо, нарешті отримуючи рядки веб сторінки. Оскільки ми ефективно зціпили декілька викликів flatMap в цій осяжності, тип результату буде пласким  Try[Iterator[String]].

Будь ласка, занотуйте, що це може бути спрощено з використанням Source#fromURL, та ще ми забули закрити наш вхідний потік в кінці. Обоє через моє рішення тримати приклад сфокусованим на предметі.

Порівняння з шаблоном

В деякій точці вашого коду ви забажаєте знати, чи примірник Try, що ви отримали як результат деякого обчислення, представляє успіх або ні, та виконати різні гілки коду в залежності від результату. Звичайно, це те місце, де ви задієте порівняння з шаблоном. Це просто можливо, оскільки обоє, Success та Failure є case класами.

Ми бажаємо відобразити запитану сторінку, якщо вона може бути отриманою, або надрукувати повідомлення, якщо ні:

1
2
3
4
5
6
import scala.util.Success
import scala.util.Failure
getURLContent("http://danielwestheide.com/foobar") match {
  case Success(lines) => lines.foreach(println)
  case Failure(ex) => println(s"Problem rendering URL content: ${ex.getMessage}")
}

Відновлення після збою

Якщо ви бажаєте встановити деякий різновид поведінки по замовчанню в випадку  Failure, вам не треба використовувати getOrElse. Альтернативою єrecover, який очікує часткову функцію, та повертає інший Try. Якщо recover викликаний до примірника Success, цей примірник повертається як є. Інакше, якщо часткова функція визначена для примірника Failure, її результат повертається як Success.

Давайте використаємо це, щоб надрукувати різні повідомлення, залежно від типу огорнутого виключення:

1
2
3
4
5
6
7
import java.net.MalformedURLException
import java.io.FileNotFoundException
val content = getURLContent("garbage") recover {
  case e: FileNotFoundException => Iterator("Requested page does not exist")
  case e: MalformedURLException => Iterator("Please make sure to enter a valid URL")
  case _ => Iterator("An unexpected error has occurred. We are so sorry!")
}

Тепер ми можемо безпечно get огорнуте значення на Try[Iterator[String]], що ми присвоїли до content, оскільки ми знаємо, що це має бути Success. Виклик content.get.foreach(println) буде продукувати друк на контролі Please make sure to enter a valid URL.

Висновок

Ідеоматична обробка помилок в Scala досить відрізняється від парадігми, відомої з таких мов, як Java або Ruby. Тип Tryдозволяє вам інкапсулювати обчислення, що можуть продукувати помилки, в контейнер, та зціпити операції обчислених значень в дуже елегантний спосіб. Ви можете застосовувати ваші відомості про колекції та значення  Option до того, як обробляти код, що може викликати помилки – все це в одноманітний спосіб.

Щоб підтримувати цю статтю помірної довжини я не розповідаю про всі методи, доступні до Try. Як Option, Try підтримує метод orElse. Методи transform та recoverWith також варті вашої уваги, та я закликаю подивитись на них.

В наступній частині ми маємо намір мат справу з Either, альтернативним типом для представлення обчислень, що можуть призвести до помилок, але з більшим полем зору застосування за межами обробки помилок.

Частина 7. Тип Either

В попередній статті я дискутував функціональну обробку помилок за допомогою Try, що з'явився в Scala 2.10. Я також згадав існування іншого, дещо подібного типу Either, що є темою цієї статті. Ви вивчите, як використовувати його, коли це робити, та які є окремі пастки.

Кажучи про це, щонайменьше на час написання, Either має деякі суттєві вади дизайну, про які вам треба знати, до такої міри, що дехто може аргументувати, чи взагалі використовувати його. так чому ми взагалі вивчаємо щодо Either взагалі?

Спершу, люди не будуть всі мігрувати свій існуючий код для використання Tryдля роботи з виключеннями, так що гарно бути в змозі розуміти внутрощі цього типу, також.

Більше того, Try не є насправді все-включено заміною для Either, тільки для його окремого застосування, тобто обробки виключень функціональним шляхом. Як би не було, Tryта Either насправді доповнюють один одного, кожний покриваючи свої випадки використання. Та, навіть з такими недоліками, які має Either, в окремих ситуаціях він може дуже гарно підходити.

Семантика

Подібно до Option та Try, Either є контейнерним типом. На відміну від вказаних типів, він приймає не один, а два параметри типу: примірник Either[A, B] може містити примірник A, або примірник B. Це відрізняється від Tuple2[A, B], що містить обоє, примірники A та B.

Either має рівно два підтипи, Left та Right. Якщо об'єкт Either[A, B]містить примірник A, тоді Either є Left. Інакше він містить примірник B та є Right.

Немає нічого в семантиці цього типу, що вказує, що один або інший підтип представляє помилку або успіх, відповідно. Фактично, Either є типом загального призначення, для використання коли ви маєте справу з ситуаціями, де результат може бути одного з двох можливих типів. Тип не менш, обробка помилок є популярним прикладом такого застосування, та за домовленістю, коли використовується таким чином, Left представляє помилковий випадок, тоді як Right містить успішне значення.

Створення Either

Створення Either є тривіальним. Обоє, Left та Right є case класами, так що якщо ви бажаєте реалізувати солідну можливість інтернет відносин, ви можете зробити це наступним чином:

1
2
3
4
5
6
7
import scala.io.Source
import java.net.URL
def getContent(url: URL): Either[String, Source] =
  if (url.getHost.contains("google"))
    Left("Requested URL is blocked for the good of the people!")
  else
    Right(Source.fromURL(url))

Тепер, якщо ви викличете getContent(new URL("http://danielwestheide.com")), ви отримаєте scala.io.Source, огорнуте в Right. Якщшо ми передамо new URL("https://plus.google.com"), результатом буде Left, що містить String.

Робота зі значеннями Either

Деяки з дуже базових речей робиться так само, як з Option або Try: Ви можете запитати примірник Either , чи він isLeft або isRight. Ви також можете порівняти його з шаблоном, що є одним з найбільш зручних та фамільярних шляхів роботи з об'єктами такого типу:

1
2
3
4
getContent(new URL("http://google.com")) match {
  case Left(msg) => println(msg)
  case Right(source) => source.getLines.foreach(println)
}

Проекції

Ви не можете, щонайменьше напряму, використовувати примірник Either як колекцію, шляхом, знайомим вам з Option та Try. Це через те, що Either розроблений бути  неупередженим.

Try є success-упередженим: він пропонує вам map, flatMap та інші методи, що всі роблять за припущення, що Try є Success, та коли це не так, вони ефективно нічого не роблять, повертаючи Failure як є.

Факт того, що Either неупереджений, означає, що перед тим, як ви будете робити з ним,  вам треба припущення, що це  Left або Right. Викликаючи left або right на значенні Either, ви отримуєте LeftProjection або RightProjection, відповідно, що загалом ліво- або право- обертка для Either.

Мепінг

Коли ви маєте огортку, ви можете викликати map для неї:

1
2
3
4
5
6
val content: Either[String, Iterator[String]] =
  getContent(new URL("http://danielwestheide.com")).right.map(_.getLines())
// content є Right, що містить рідяки, повернуті  getContent
val moreContent: Either[String, Iterator[String]] =
  getContent(new URL("http://google.com")).right.map(_.getLines)
// moreContent є Left, вже повернутий getContent

Без різниці, чи Either[String, Source] в цьому прикладі є Left або Right, він буде відображений на Either[String, Iterator[String]]. Якщо він викликаний для Right, значення зсередини буде трансформовано. Якщо для Left, воно буде повернуте без змін.

Звичайно, ми можемож зробити те ж саме з LeftProjection:

1
2
3
4
5
6
val content: Either[Iterator[String], Source] =
  getContent(new URL("http://danielwestheide.com")).left.map(Iterator(_))
// content є Right, що містить Source, вже повернутий getContent
val moreContent: Either[Iterator[String], Source] =
  getContent(new URL("http://google.com")).left.map(Iterator(_))
// moreContent є Left, що містить msg, що повернув getContent в Iterator

Тепер, якщо Either є Left, значення огортки трансформується, тоді як Right повернеться без змін. Так чи інакше, результат буде типу Either[Iterator[String], Source].

Будь ласка, зауважте, що метод map визначений на типах проекції, не на Either, але він повертає значення типу Either, не проекцію. В цьому Either відхиляється від інших контейнерних типів, щ ови знаєте. Причиною цього було бажання зробити Either неупередженим, але як ви побачите, це може призвести до дуже небажаних проблем в деяких випадках. Це також означає, що якщо ви бажаєте зціпити декілька викликів до map, flatMap та подібних, ви завжди маєте запитати вашу бажану проекцію знову і знову перед кожним мепінгом.

Плаский мепінг

Проекції також підтримують плаский мепінг, уникаючи загальної проблеми створення внутрішніх та зовнішніх типів Either , чим ви скінчите, якщо ви вкладете декілька викликів map.

Я покладаю дуже високі надії до вашого опору зневірі, пропонуючи вам повністю штучний приклад. Нехай ми бажаємо обчислити середнє значення рядків в двох статтях. Ви завжди бажали зробити це, вірно? Ось як ми можемо вирішити цю складну проблему:

1
2
3
4
5
val part5 = new URL("http://t.co/UR1aalX4")
val part6 = new URL("http://t.co/6wlKwTmu")
val content = getContent(part5).right.map(a =>
  getContent(part6).right.map(b =>
    (a.getLines().size + b.getLines().size) / 2))

Що ми отримаємо в результаті є Either[String, Either[String, Int]]. Тепер, коли content є вкладеною структурою  Right, ми можемо сплющити його, викликаючи метод joinRight на ньому (ви також маєте доступний joinLeft для сплющення вкладену структуруLeft).

Однак ми можемо уникнути створення ціх вкладених структур. Якщо ми flatMap на нашому зовнішньому RightProjection, ми отримаємо більш приємний тип результату, розпаковуючи Right внутрішнього Either:

1
2
3
val content = getContent(part5).right.flatMap(a =>
  getContent(part6).right.map(b =>
    (a.getLines().size + b.getLines().size) / 2))

Тепер content є пласким Either[String, Int],, який робить його значно більше приємним для роботи, наприклад використовувати порівняння з шаблоном.

For осяжності

Тепер ви, можливо, вподобали робити з осяжностями в сумісний спосіб на різних типах даних. Ви можете зробити це, також, з проекціями Either, але сумна правда в тому, що це вже не так мило, та є речі, що ви не в змозі зробити, не вдаючись до бридких обхідних шляхів.

Давайте перепишемо наш приклад flatMap, використовуючи замість цього осяжність:

1
2
3
4
5
def averageLineCount(url1: URL, url2: URL): Either[String, Int] =
  for {
    source1 <- getContent(url1).right
    source2 <- getContent(url2).right
  } yield (source1.getLines().size + source2.getLines().size) / 2

Це не дуже погано. Зауважте, що ми маємо викликатиright на кожному Either, що ми використовуємо в наших генераторах.

Тепер давайте спробуємо переробити це для осяжності  – оскільки вираз yield трохи задовгий, ми бажаємо виділити деякі його частини в визначення значень в нашій for осяжнсоті:

1
2
3
4
5
6
7
def averageLineCountWontCompile(url1: URL, url2: URL): Either[String, Int] =
  for {
    source1 <- getContent(url1).right
    source2 <- getContent(url2).right
    lines1 = source1.getLines().size
    lines2 = source2.getLines().size
  } yield (lines1 + lines2) / 2

Це не компілюється! Причина буде яснішою, якщо ми перевіримо, чому відповідає ця осяжність, якщо ми приберемо цукор. Це транслюється в дещо, подібне наступному, хоча менш читабельне:

1
2
3
4
5
6
7
8
def averageLineCountDesugaredWontCompile(url1: URL, url2: URL): Either[String, Int] =
  getContent(url1).right.flatMap { source1 =>
    getContent(url2).right.map { source2 =>
      val lines1 = source1.getLines().size
      val lines2 = source2.getLines().size
      (lines1, lines2)
    }.map { case (x, y) => x + y / 2 }
  }

Проблема в тому, що включачи визначення значень в нашу осяжність, автоматично вводиться новий виклик до map  – на результаті попереднього виклику до map, що повернув Either, не RightProjection. Як ви знаєте, Either не визначає метод map, що трохи дратує компілятор.

Ось де Either показує свою огидну гримасу. В цьому прикладі визначення значень не є суто необхідними. Якщо це буде так, ви можете піти кружним шляхом, замінюючи кожне визначення значення на генератор, от так:

1
2
3
4
5
6
7
def averageLineCount(url1: URL, url2: URL): Either[String, Int] =
  for {
    source1 <- getContent(url1).right
    source2 <- getContent(url2).right
    lines1 <- Right(source1.getLines().size).right
    lines2 <- Right(source2.getLines().size).right
  } yield (lines1 + lines2) / 2

Важливо бути попередженим про цю слабкість дизайну. Це не робить Either непридатним, але може призвести до серйозних головних білей, якщо ви не маєте уяви, що відбувається.

Інші методи

Проективні типи мають деякі інші корисні методи:

Ви можете перетворити ваш примірник Either на Option , викликаючиtoOption на одній з його проекцій. Наприклад, якщо ви маєте e типу Either[A, B], e.right.toOption поверне Option[B]. Якщо ваш примірник Either[A, B] є Right, тоді Option[B] буде Some. Якщо це Left, це буде None. Звичайно, зворотня поведінка може бути досягнута, коли викликаєтся toOption на LeftProjection вашого Either[A, B]. Якщо вам треба послідовність з любого одного значення, або нічого, замість цього використовуйте toSeq.

Складання (фолдінг)

Якщо ви бажаєте трансформувати значення Either незалежно від того, це Left абоRight, ви можете зробити це силами методу fold, що визначений на Either, що очікує дві функції тарнсформації з однаковим типом результата, одну для того, щоб викликатись, коли Either є Left, другу - коли Right.

Щоб продемонструвати це, давайте скомбінуємо дві операції мепінгу, що ми реалізували на LeftProjection та RightProjection вище:

1
2
3
4
val content: Iterator[String] =
  getContent(new URL("http://danielwestheide.com")).fold(Iterator(_), _.getLines())
val moreContent: Iterator[String] =
  getContent(new URL("http://google.com")).fold(Iterator(_), _.getLines())

В цьому прикладі ми перетворили наші Either[String, Source] на Iterator[String], не важливо, це Left або Right. Ви можете точно так же повернути новий Either знову, або виконати побічний  ефект, та повернути Unit з двох функцій. Як такий, виклик fold провадить гарну альтернативу до порівняння шаблонів.

Коли використовувати Either

Тепер, коли ми побачили, як робити зі значеннями Either, та що ви маєте пам'ятати, давайте перейдемо до деяких специфічних випадків використання.

Обробка помилок

Ви можете використовувати Either для обробки виключень, дуже подібно до Try. Eitherмає одну перевагу над Try: ви можете мати більш специфічні типи помилок під час компіляції, тоді як Try весь час використовує Throwable. Це означає, що Eitherможе бути гарним вибором для очікуваних помилок.

Вам треба реалізувати метод, на кшталт такого, делегуючи до дуже корисного о'бєкту  Exception з пакункуscala.util.control :

1
2
3
import scala.util.control.Exception.catching
def handling[Ex <: Throwable, T](exType: Class[Ex])(block: => T): Either[Ex, T] =
  catching(exType).either(block).asInstanceOf[Either[Ex, T]]

Причиною, чому ви бажаєте зробити це - тому що методи, що провадить  scala.util.Exception, дозволяють вам перехоплювати тільки певні типи виключень, та результуючий тип часу компіляції завжди буде Throwable.

Маючи такий метод, ви можете передати дале очікувані виключення в Either:

1
2
3
import java.net.MalformedURLException
def parseURL(url: String): Either[MalformedURLException, URL] =
  handling(classOf[MalformedURLException])(new URL(url))

Ви будете мати інші очікувані умови обробки, та не всі вони закінчаться в коді третіх сторін, що підіймає виключення для обробки, як в прикладі вище. В ціх випадках, немає реальної потреби самому підіймати виключення, тільки для перехоплення його, та огорнути його в Left. Замість цього просто визначте ваш власний тип помилки, бажано як case клас, та поверніть Left, огортаючи примірник цієї помилки.

Ось приклад:

1
2
3
4
5
6
case class Customer(age: Int)
class Cigarettes
case class UnderAgeFailure(age: Int, required: Int)
def buyCigarettes(customer: Customer): Either[UnderAgeFailure, Cigarettes] =
  if (customer.age < 16) Left(UnderAgeFailure(customer.age, 16))
  else Right(new Cigarettes)

Ви повинні уникати використання Either для огортання неочікуваних виключень. Try робить це краще, без усіх слабкостей, з якими ви стикаєтесь, коли має справу з Either.

Обробка колекцій

Загалом, Either досить гарно підходить, якщо ви бажаєте обробляти колекції,  де для деяких елементів в цій колекції це може призводити до проблематичної умови, але не повинно напряму призводити до виключення, що буде призводити до переривання обробки залишку колекції.

Давайте уявимо, що для нашої індустріальної системи веб відношень, ми використовуємо деякий різновид чорного списку:

1
2
3
4
5
6
7
8
9
type Citizen = String
case class BlackListedResource(url: URL, visitors: Set[Citizen])

val blacklist = List(
  BlackListedResource(new URL("https://google.com"), Set("John Doe", "Johanna Doe")),
  BlackListedResource(new URL("http://yahoo.com"), Set.empty),
  BlackListedResource(new URL("https://maps.google.com"), Set("John Doe")),
  BlackListedResource(new URL("http://plus.google.com"), Set.empty)
)

BlackListedResource представляє URL заблокованих веб сторінки, плюс список людей, хто спробував навідати цю сторінку.

Тепер ми бажаємо обробити цей чорний список, де наша головні ціль ідентифікувати проблематичних громадян, тобто тих, хто намагався навідувати заблоковані сторінки. В той же час ми бажаємо ідентифікувати підозрілі веб сторінки – якщо один громаданин намагався навідатись на заблоковану сторінку, ми повинні вважати, що наші підозрювані якось оминають на фільтр, та нам треба розслідувати це.

Ось як ми можемо обробити наш чорний список:

1
2
3
4
val checkedBlacklist: List[Either[URL, Set[Citizen]]] =
  blacklist.map(resource =>
    if (resource.visitors.isEmpty) Left(resource.url)
    else Right(resource.visitors))

Нам треба створити послідовність зі значень Either, з примірниками Left, що представляють підозрілі URL, та Right, що містять набори проблемним громадянам. Це робить майже легким ідентифікувати обоє, наших проблемних громадян, та підозрілі веб сторінки:

1
2
val suspiciousResources = checkedBlacklist.flatMap(_.left.toOption)
val problemCitizens = checkedBlacklist.flatMap(_.right.toOption).flatten.toSet

Ці, більш загальні, приклади використання за межами обробки виключень, це те, де  Either дійсно сяє.

Висновок

Ви навчились, як використовувати Either, які тут є пастки, та потім покладати його до використання в вашому коді. Цей тип не позбавлений слабкостей, та чи ви бажаєте мати з ним справу, та вбудовувати в свій код, покладається виключно на вас.

На практиці, як ви побачите, тепер, коли ви маєте Try, для нього не буде дуже багато застосувань. Тим не менш, добре знати про нього, з двох причин: в ситуаціях, де він може бути бажаним інструментом, та щоб розуміти код pre-2.10 Scala, де він використовується для обробки помилок. 

Частина 8: Ласково просимо до  майбутнього

Як зацікавлений та заповзятий Scala розробник, ви вже, напевне, чули про підхід Scala до стправ конкурентності – або можливо, саме це захопило вас в першу чергу. Вказаний підхід має обгрунтування щодо конкурентності, та написання гарно поводячих себе, конкурентних програм значно простіше, ніж за використання низькорівневих API конкуренції, яким ви протистояли в більшості інших мов. 

Один з наріжних каменів цього підходу є Future, іншим є Actor. Останній буде предметом окремої глави. Я буду пояснювати що гарного, та як ви можете викорстовувати їх в функціональний спосіб.

Будь ласка, переконайтесь, що ви маєте версію 2.9.3 або пізнішу, якщо ви бажаєте залучити свої руки до справи, та спробувати приклади власноруч. Можливості, що ми дискутуємо тут, були вбудовані в ядро Scala з дистрибутива релізу 2.10.0, та потім зворотньо портовані до Scala 2.9.3. В оригіналі, з дещо іншим API, вони були частиною конкурентного тулкиту Akka.

Чому послідовний код може стати поганим

Уявімо, ми бажаємо приготувати капучіно. Ви можете просто виконати наступні кроки, один за одним:

  1. Змоліть потрібні зерна кави
  2. Підігрійте декілька води
  3. Зваріть еспресо, використовуючи змолену каву та підігріту воду
  4. Зпінте деяке молоко
  5. Змішайте еспресо та зпінене молоко, щоб отримати капучіно

Перекладаючи на код Scala, ви маєте зробити щось таке:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import scala.util.Try
// Деякі псевдоними типів, тільки для отримання більш осмислених сигнатур методів:
type CoffeeBeans = String
type GroundCoffee = String
case class Water(temperature: Int)
type Milk = String
type FrothedMilk = String
type Espresso = String
type Cappuccino = String
// умовна реалізація окремих кроків:
def grind(beans: CoffeeBeans): GroundCoffee = s"ground coffee of $beans"
def heatWater(water: Water): Water = water.copy(temperature = 85)
def frothMilk(milk: Milk): FrothedMilk = s"frothed $milk"
def brew(coffee: GroundCoffee, heatedWater: Water): Espresso = "espresso"
def combine(espresso: Espresso, frothedMilk: FrothedMilk): Cappuccino = "cappuccino"
// деякі виключення для речей, що можуть пійти не так на окремих кроках
// (нам знадобляться деякі пизніше, використовуйте інші коли експирементуємо
// з кодом):
case class GrindingException(msg: String) extends Exception(msg)
case class FrothingException(msg: String) extends Exception(msg)
case class WaterBoilingException(msg: String) extends Exception(msg)
case class BrewingException(msg: String) extends Exception(msg)
// проходимо цей код послідовно:
def prepareCappuccino(): Try[Cappuccino] = for {
  ground <- Try(grind("arabica beans"))
  water <- Try(heatWater(Water(25)))
  espresso <- Try(brew(ground, water))
  foam <- Try(frothMilk("milk"))
} yield combine(espresso, foam)

Робити так має декілька переваг: ви отримуєте дуже читабельні крок-за-кроком інструкції, що робити. Більше того, ви, скоріше, не будете збентажені при приготуванні капучіно в цей шлях, бо ви уникаєте перемикання контексту.

З другого боку, приготування вашого капучіно в такій манері крок-за-кроком означає, що ваш мозок та ваше тіло буде простоювати на довгих відтинках часу під час цього процесу. Доки ви ждете змолювання кави, ви ефективно заблоковані. Тільки коли це скінчиться, ви будете в змозі підігрівати воду, і так далі.

Ясно, що це розпорошення цінних ресурсів. Дуже можливо, що ви б хотіли розпочати декілька кроків, та виконувати їх одночасно. Коли ви побачили, що вода та змолювання кави готове, ви починаєте варити еспресо, та при цьому починаючи процес зпінення молока.

Це, насправді, так само, коли ви пишете якійсь код. Веб сервер має дуже багато потоків для обробки запитів, та створення відповідних відповідей. Ви не бажаєте блокувати ці ціння потоки, очікуючи результати запиту до бази даних, або для виклику іншого HTTP сервіса. Замість цього, ви бажаєте асинхронну модель програмування, та неблокуючий IO, так що під час обробки запиту, що чекає на відповідь від бази даних, потік веб сервера, що обробляє цей запит, може обробляти потреби деякого іншого запиту, замість простоювати в стороні.

“Я чув ви любите зворотні виклики, так що я поставив зворотній виклик в ваш зворотній виклик!”

Звичайно, ви знаєте все це - що за допомогою Node.js наразі стало предметом несамовитості серед крутих дітлахів. Підхід, використаний в Node.js та деяких інших є комунікація через зворотні виклики, виключно. На жаль, це може дуже просто призвести до розростання безладу в викликах викликів через виклики, що зробить ваш код складним для читання та налаштування.

Scala Future дозволяє зворотні виклики, як ви скоро побачите, але вона провадить значно кращі альтернативи, так що вам вони, напевне, не дуже знадобляться. 

“Я знаю Futures, і вони повністю даремні!”

Ви можете бути також знайомим з іншими реалізаціями Future, більш замітною з яких є та, що провадиться в Java. Насправді вам нема чого багато робити з ціма Java future, окрім перевіряти, ци вона завершилась, або просто блокуватись, доки вона завершиться. Коротко кажучи, вони майже даремні, та напевне не додають радощів при роботі. 

Якщо ви думаєте що майбутнє Scala щось подібне до цього, приготуйтесь до сюрпризу. Починаємо!

Семантика Future

Scala Future[T] розташоване в пакунку scala.concurrent, є контейнерним типом, що представляє обчислення, що, як очікуєтсья, при нагоді завершиться значенням типу T. Гаразд, обчислення може пійти не так, або вийти в таймаут, так що коли майбутне завершиться, воно може взагалі не бути успішним в кінці кінців, і тоді воно міститиме виключення.

Future є одноразовим контейнером – коли майбутнє завершене, воно ефективно незмінне. Також тип Future провадить інтерфейс тільки для читання обчисленого значення. Завдання запису the обчисленного значення досягається через Promise. Таким чином, є ясна сепарація між сферами в дизайні API. В цьому пості ми сфокусуємось на попередньому, відклавши використання типу Promise до наступної статті в цій серії.

Робота з Future

Є декілька шляхів, як ви можете робити з майбутнім в Scala, що ми збираємось перевірити, переписавши наш приклад с капучіно, щоб задіяти тип Future. З початку, нам треба переписати всі функції, що можуть бути виконані конкурентно, так, щоб вони могли безпосередньо повертали Future, замість обчислення своїх результатів в блокуючий спосіб:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import scala.concurrent.future
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration._
import scala.util.Random

def grind(beans: CoffeeBeans): Future[GroundCoffee] = Future {
  println("start grinding...")
  Thread.sleep(Random.nextInt(2000))
  if (beans == "baked beans") throw GrindingException("are you joking?")
  println("finished grinding...")
  s"ground coffee of $beans"
}

def heatWater(water: Water): Future[Water] = Future {
  println("heating the water now")
  Thread.sleep(Random.nextInt(2000))
  println("hot, it's hot!")
  water.copy(temperature = 85)
}

def frothMilk(milk: Milk): Future[FrothedMilk] = Future {
  println("milk frothing system engaged!")
  Thread.sleep(Random.nextInt(2000))
  println("shutting down milk frothing system")
  s"frothed $milk"
}

def brew(coffee: GroundCoffee, heatedWater: Water): Future[Espresso] = Future {
  println("happy brewing :)")
  Thread.sleep(Random.nextInt(2000))
  println("it's brewed!")
  "espresso"
}

Тут є декілька речей, що потребує пояснення.

Зпершу, є метод apply об'єкта-компанйона Future, що потребує двох аргументів:

1
2
3
object Future {
  def apply[T](body: => T)(implicit execctx: ExecutionContext): Future[T]
}

Обчислення, що виконується асинхронно, передається як параметр по імені body. Другий аргумент, в окремому списку аргументів, є неявним, що означає, що ми не маємо вказувати його, якщо співпадаюче неявне значення визначене десь в полі зору. Ми гарантуємо, що це наш випадок, імпортуючи глобальний контекст виконання.

ExecutionContext - це щось, що може виконувати наше майбутнє, та ви можете думати про це, як про пул потоків. Оскільки ExecutionContext доступний неявно, ми маємо тільки одноелементний список аргументів. Списки з одного елементу можуть бути заточені в фігурні дужки, замість звичайних. Люди часто використовують це, коли викликають методfuture, після чого воно виглядає якби ми використовували можливість мови, а не викликаємо звичайний метод. ExecutionContext є неявним параметром для віртуально всіх Future API.

Більше того, звичайно, в цьому прикладі ми насправді не обчислюємо нічого, тому ми покладаємо деяке випадкове очікування, просто для цілей демонстрації. Ми також друкуємо в консоль перед та після наших “обчислень”, щоб зробити невизначену та конкурентну природу нашого кода яснішою.

Обчислення значення, що повертається Future, буде починатись в деякий невизначений час після створення Future, коли деякий потік буде присвоєне йому з ExecutionContext.

Зворотні виклики

Іноді, коли речі прості, використання зворотнього виклику може бути повністю гарним. Зворотні виклики для майбутнього є частковими функціями. Ви можете передавати зворотній виклик до методаonSuccess. Він буде викликаний, тільки якщо Future обчислиться успішно, та якщо так, він отримає обчислене значення в якості входу:

1
2
3
grind("arabica beans").onSuccess { case ground =>
  println("okay, got my ground coffee")
}

Подібним чином, ви можете зареєструвати зворотній виклик по збою, за допомогою метода onFailure. Ваш зворотній виклик буде отримувати Throwable, але він буде викликаний, тільки якщо Future не завершиться успішно.

Зазвичай краще комбінувати ці двоє, та реєструвати зворотній виклик завершення, що буде обробляти обоє випадки. Вхідний параметр для зворотнього виклику є Try:

1
2
3
4
5
import scala.util.{Success, Failure}
grind("baked beans").onComplete {
  case Success(ground) => println(s"got my $ground")
  case Failure(ex) => println("This grinder needs a replacement, seriously!")
}

Оскільки ми передаємо жарені зерна, виключення виникає в методі grind, що призведе до завершення Future з Failure.

Компонування майбутнього

Використання зворотніх викликів може бути досить болючим, якщо ви маєте вкладені зворотні виклики. На щастя, ви не маєте робити це! Реальна міць майбутнього Scala в тому, що їх можна компонувати.

Якщо ви слідували цій сериї, ви напевне помітили, що всі ваші контейнерні типи, що ми дискутували, роблять можливим відображення їх, пласке відображення, або використання іх в осяжностях. Та якщо я кажу, що Future також є контейнерним типом, це означає, що тип Scala Future дозволяє вам робити все те ж саме, і це, взагалі, не є сюрпризом. 

Реальне запитання наступне: що це насправді означає, виконувати ці операції до того, що навіть досі не завершилось?

Відображення майбутнього

Чи не баажли ви подорожувати в часі, та бути тим, хто встановлює мапу майбутнього? Як розробник Scala, ви можете робити саме це! Уявіть, що коли вода зігрілась, ви бажаєте перевірити, чи температура в нормі. Ви можете зробити це, відображуючи Future[Water] на Future[Boolean]:

1
2
3
4
val temperatureOkay: Future[Boolean] = heatWater(Water(25)).map { water =>
  println("we're in the future!")
  (80 to 85).contains(water.temperature)
}

Future[Boolean] присвоєне до temperatureOkay, буде з часом містити успішно обчислене значення. Змініть реалізацію heatWater,  так що коли вона викликає виключення (можливо через те, що ваш нагрівач вибухнув, або щось інше) , та дивіться, як we're in the future не буде ніколи надрукованим в консолі.

Коли ви пишете функцію, що ви передаєте до map, ви в майбутньому, або скоріше в можливому майбутньому. Ця функція відображення виконуєтья так швидко, як тільки  ваш примірник  Future[Water] був завершений успішно. Однак, плин часу, в якому це трапляється, може бути не тим, в якому живете ви. Якщо ваш примірник Future[Water] схибить, те, що відбувається в функції, що ви передаєте в map, ніколи не відбудеться. Замість цього, результат виклику mapбудеFuture[Boolean], що містить Failure.

Утримування майбутнього пласким

Якщо обчислення одного Future залежить від результату іншого, ви, напевне, бажаєте вдатися до flatMap щоб виключити глибоко вкладеної структури для майбутніх.

Наприклад, давайте уявімо, що процес насправді виміряє температуру за деякий час, так що ви бажаєте визначати годніть температури також асинхронно. Ви маєте функцію, що приймає інтерфейс Water та повертає Future[Boolean]:

1
2
3
def temperatureOkay(water: Water): Future[Boolean] = Future {
  (80 to 85).contains(water.temperature)
}

Використання flatMap замість map дає Future[Boolean] замість Future[Future[Boolean]]:

1
2
3
4
5
6
val nestedFuture: Future[Future[Boolean]] = heatWater(Water(25)).map {
  water => temperatureOkay(water)
}
val flatFuture: Future[Boolean] = heatWater(Water(25)).flatMap {
  water => temperatureOkay(water)
}

І знову, функція відображення виконується після (якщо взагалі) примірник Future[Water] був завершений успішно, як надіємось, з допустимою температурою.

For осяжності

Замість виклику flatMap, ви будете звичайно писати for осяжності, що в основному те ж саме, але краще виглядає. наш приклад вище може бути переписаний таким чином:

1
2
3
4
val acceptable: Future[Boolean] = for {
  heatedWater <- heatWater(Water(25))
  okay <- temperatureOkay(heatedWater)
} yield okay

Якщо ви маєте декілька обчислень, що можуть бути обчислені паралельно, вам треба потурбуватись, щоб ви створили відповідні примірники Futureза межами for осяжності.

1
2
3
4
5
6
7
8
def prepareCappuccinoSequentially(): Future[Cappuccino] = {
  for {
    ground <- grind("arabica beans")
    water <- heatWater(Water(20))
    foam <- frothMilk("milk")
    espresso <- brew(ground, water)
  } yield combine(espresso, foam)
}

Це гарно читається, але оскільки for осяжність лише інше представлення для вкладених викликів flatMap, це означає, що Future[Water], створений в heatWater є тільки насправді уособленим після того, як Future[GroundCoffee] був обчислений успішно. Ви можете перевірити це, поглянувши на послідовний вивід на консоль, що іде від функцій, що ми тільки но реалізовали.

Таким чином переконайтесь, що створили всі незалежні майбутні перед осяжністю:

1
2
3
4
5
6
7
8
9
10
11
def prepareCappuccino(): Future[Cappuccino] = {
  val groundCoffee = grind("arabica beans")
  val heatedWater = heatWater(Water(20))
  val frothedMilk = frothMilk("milk")
  for {
    ground <- groundCoffee
    water <- heatedWater
    foam <- frothedMilk
    espresso <- brew(ground, water)
  } yield combine(espresso, foam)
}

Тепер ми створили три майбутніх перед for осяжністю, що стартують будучи безпосередьно завершеними, та виконуються конкурентно. Якщо ви подивитесь на вивід в консолі, ви побачите, що вивід недетермінований. Єдина річ, що певна, це те, що вивід"happy brewing" буде останнім. Оскільки метод, що викликається, потребує значень, що походять від інших двох майбутніх, він створюється в нашій  for осяжності, тобто після того, як ті майбутні завершені успішно.

Проекції збоїв

Ви маєте бути попереджені, що Future[T] є схильним до успіху, дозволяючи вам використовувати map, flatMap, filter etc. за припущення, що він завершується успішно. Іноді, ви можете побажати зробити це, в цей функціональний спосіб для плину часу, де речі ідуть невірно. Викликаючи метод failed на примірнику Future[T], ви отримаєте проекцію збою, що є Future[Throwable]. Тепер ви можете, наприклад, застосувати map до цього Future[Throwable], та ця функція відображення буде виконуватись, якщо оригінальний Future[T] був завершений зі збоєм.

Огляд

Ви побачили Future, та це виглядає блискуче! Факт, що це тільки інший контейнерний тип, що може бути скомпонований та використаний в функціональний спосіб, робить роботу з ним дуже приємною.

Перетворення блокуючого кода на конкурентний може бути досить простим, огортаючи його в виклик до future. Однак, краще краще для початку бути неблокуючим. Щоб досягти цього, ви маєте зробити Promise для завершення Future. Це, та використання майбутнього на практиці буде темою наступної частини в цій серії.

Розділ 9: Promise та Futures на практиці

В попередньому розділі серії я ввів тип Future, його підлеглу парадігму, та як використовувати його для написання високо читабельного та компоновного кода з асинхронним виконанням.

В цій главі я також споминав Future як дійсно одну частину пазла: це тип тільки для читання, що дозволяє вам робити зі значеннями, та обробляти відмови, та робити це в елегантний спосіб. Однак, щоб ви могли читати обчислене значення з Future, має бути шлях для деякох іншої частини нашої програми, щоб покласти туди це значення. В цьому пості я покажу вам, як це робиться за допомогою типу Promise, за чим послідують деякі інструкції, щодо того, як використовувати майбутні та обіцянки на практиці.

Обіцянки

В попередній главі про майбутнє ми мали послідовний блок коду, що ми передавали до методу apply об'єкта-компанйона Future, та, маючи ExecutionContext в полі зору, він магічно виконував цей блок асинхронно, повертаючи результат як Future.

Хоча це простий шлях отримати Future, коли воно вам треба, є альтернативний шлях для створення примірників Future, та мати завершеними, з успіхом або збоєм. Тут Future провадить інтерфейс виключно для запитів, Promise є дружнім типом, що дозволяє вам завершити Future, покладаючи значення до нього. Це робиться рівно один раз. Коли Promiseзавершений, його вже неможливо змінити.

Примірник Promise завжди пов'язаний рівно до одного примірника Future. Якщо ви викличете метод apply для Future знову в REPL, ви, напевно помітите, що Future поверне також Promise:

1
2
3
4
5
import concurrent.Future
import concurrent.ExecutionContext.Implicits.global
val f: Future[String] = Future { "Hello world!" }
// REPL output: 
// f: scala.concurrent.Future[String] = scala.concurrent.impl.Promise$DefaultPromise@793e6657

Об'єкт, що ви отримаєте назад, буде DefaultPromise, що реалізує  обоє, Future та Promise. Однак, це лише деталь реалізації. Future та Promise, до якого він належить, може дуже добре бути окремими об'єктами.

Що показує цей малий приклад, це що немає очевидного шляху завершити Future, інакше ніж через Promise – метод apply на Future є тільки милою функцією-допоміжником, що захищає вас від цього.

Тепер давайте подивимось, як ви можете докласти свої руки, та напряму поробити з типом Promise.

Обіцянка рожевого майбутнього

Коли ми кажемо про обіцянки, вони можуть справдитись, або ні. Очевидні приклади є політика, вибори, внески під час компанії, та подальша законотворчість.

Уявімо політикана, що під час обрання до кабінета обіцяв виборцям зниження податків. Це можна представити як Promise[TaxCut], що ви можете створити, викликаючи метод apply на об'єкті-компанйона Promise, таким чином:

1
2
3
4
5
6
import concurrent.Promise
case class TaxCut(reduction: Int)
// або дає тип як параметр типу методу-фабрики:
val taxcut = Promise[TaxCut]()
// або дає підказку компілятору, вказуючи тип вашого val:
val taxcut2: Promise[TaxCut] = Promise()

Коли ви створили Promise, ви можете отримати Future, що належить йому, викликаючи метод future на екземплярі Promise:

1
val taxcutF: Future[TaxCut] = taxcut.future

Повернений Future не не бути тим самим об'єктом, що і Promise, але виклики метода futureна Promise декілька разів буде однозначно повертати той же об'єкт, так що є відношення один-до-одного між Promise та Future зберігаєтся.

Завершення Promise

Як тільки ви створили Promise, та повідомили світові, що ви будете доставляти до нього в очікуваному Future, ви зробили все можливе, щоб це трапилось.

В Scala ви можете завершити Promise або успіхом, або невдачею.

Доставка до вашого Promise

Щоб завершити Promise успішно, ви викликаєте його метод success, передаючи йому значення, що Future асоціює з тим, що має бути:

1
taxcut.success(TaxCut(20))

Коли ви це зробили, цей примірник Promise більше не записується, та майбутні спроби зробити це призведуть до виключення.

Також завершення вашого Promise таким чином призведе до успішного завершення асоційованого Future. Тепер будуть викликані любі обробники успіху або завершення на майбутньому, або, наприклад, якщо ви відображуєте майбутнє, функція відображення буде викликана саме тепер.

Звичайно завершення Promise та обробка завершеного Future не буде відбуватись в одному потоці. Більш вірогідно, що ви створите свій Promise, розпочнете обчислення його значення в іншому потоці, та безпосередньо повернете незавершений Future викликаючому.

Щоб проілюструвати це, давайте зробимо щось на кшталт цього в обіцянці зниження податків:

1
2
3
4
5
6
7
8
9
10
11
12
object Government {
  def redeemCampaignPledge(): Future[TaxCut] = {
    val p = Promise[TaxCut]()
    Future {
      println("Починаємо наступний період каденції")
      Thread.sleep(2000)
      p.success(TaxCut(20))
      println("Ми зменшили податки! Ви маєте переобрати нас!!!!1111")
    }
    p.future
  }
}

Будь ласка, не засмучуйтесь через використання метода apply до об'єкта-комапанйона Futureв цьому прикладі. Я просто використовую його, оскільки це так зручно для виклику блоку коду асинхронно. Я можу так само реалізувати обчислення результату (що включає багато сну) в Runnable, що виконуєтья асинхронно в ExecutorService, з багато більшим  шаблонним кодом. Смисл в тому, що Promise не завершений у викликаючому потоці.

Тепер давайте спокутувати наші обіцянки кампанії, та додамо функцію-зворотній виклик onComplete до нашого майбутнього:

1
2
3
4
5
6
7
8
9
import scala.util.{Success, Failure}
val taxCutF: Future[TaxCut] = Government.redeemCampaignPledge()
  println("Тепер, коли вони обрані, перевіримо, як вони пам'ятають обіцянки...")
  taxCutF.onComplete {
    case Success(TaxCut(reduction)) =>
      println(s"Диво! Вони дійсно зменшили податки на $reduction відсотків!")
    case Failure(ex) =>
      println(s"Вони не дотримались обіцяного! Знову! Тому що ${ex.getMessage}")
  }

Якщо ви спробуєте це декілька разів, ви побачите, що порядок друку на консоль не визначений. Іноді обробник буде викликаний, та підпаде під випадок успіху.

Порушення обіцянок по-джентельменськи

Як политикан, ви напевне не раз порушували свої обіцянки. Як розробник Scala, ви часом не маєте іншого вибору, так чи інше. Якщо це трапляється, ви все ше можете завершити ваш екземпляр  Promise тактично, викликавши його метод failure, та передати йому виключення:

1
2
3
4
5
6
7
8
9
10
11
12
13
case class LameExcuse(msg: String) extends Exception(msg)
object Government {
  def redeemCampaignPledge(): Future[TaxCut] = {
       val p = Promise[TaxCut]()
       Future {
         println("Починається новий період каденції")
         Thread.sleep(2000)
         p.failure(LameExcuse("глобальна економічна криза"))
         println("Ми порушили обіцянку, але нас зрозуміють")
       }
       p.future
     }
}

Це реалізація метода redeemCampaignPledge() підходить до багатьої порушених обіцянок. Коли ви завершили Promise методом failure, він більше не записуєтсья, так само, як випадку метода success. Асоційований Future тепер завершиться з Failure, також, так що функція зворотнього виклику вище виконає випадок невдачі.

Якщо ви вже маєте Try, ви також можете завершити Promise викликом метода complete. Якщо Try є Success, асоційований Futureбуде завершений успішно, зі значенням всередині Success. Якщо це Failure, Future завершиться невдачею.

Базова на Future програмування на практиці

Якщо ви бажаєте використати парадігму на базі майбутнього, щоб покращити маштабованість вашого застосування, ви маєте розробити ваше застосування як неблокуюче від самого початку, що, загалом, означає, що функції на всіх рівнях у всьому вашому застосуванні є асинхронними, та повертають майбутнє.

Вірогідний випадок на сьогодні є розробка веб застосування. Якщо ви використовуєте сучасний веб фреймворк Scala, він дозволить вам повертати ваші відповіді як щось на кшталт Future[Response], замість блокування, та потім повертання вашого завершеного Response. Це важливо, оскількі це дозволяє вашому веб серверу оброляти величезну кількість відкритих з'єднань за допомогою порівняно малої кількості потоків. Коли ви весь час надаєте вашому серверу Future[Response], ви максимізуєте використання виділеного пулу потоків вашого сервера. 

В кінці кінців, сервіс в вашому застосуванні може зробити декілька викликів до рівня вашої бази даних, та/або деякий зовнішній веб сервіс, отримуюючи декілька майбутніх, та потім скомпонувати їх результат в один загальний Future, все в дуже читальній for осяжності, як ви бачили в попередній главі. Веб прошарок буде перетворювати цей Future в Future[Response].

Однак як це реалізовати на практиці? Є три випадкі, які ви маєте розглянути:

Неблокуючий IO

Ваше застосування буде, найбільш вірогідно, включати багато IO. Наприклад, веше веб застосування буде звертатись до бази даних, та воно може діяти як клієнт, що викликає інші веб сервіси.

Якщо це загалом можливо, використовуйте бібліотеки, що базуються на неблокуючому Java IO, або використовуючи Java NIO API напряму, або через бібліотеки, як Netty. Такі бібліотеки, також, можуть обслуговувати багато з'єднань з пулом потоків помірного розміру. 

Розробка такої бібліотеки самому є одним з декількох місць, де пряма робота з Promise має багато сенсу.

Блокуючий IO

Іноді немає доступної неблокуючої бібліотеки NIO. Наприклад, більшість драйверів баз даних, що ви знайдете в світі Java, наразі використовують блокуючий IO. Якщо ви зробили запит до вашої бази даних за допомогою такого драйверу, щоб відповісти на HTTP запит, цей виклик буде зроблений на потоці веб сервера. Щоб цникнути цього, розмістіть весь код, що розмовляє з базо даних, в блок future, таким чином:

1
2
3
4
// отримаємо назад Future[ResultSet] або щось подібне:
Future {
  queryDB(query)
}

До тепер ми завжди використовували неявний доступний глобальний ExecutionContextдля виконання блоків майбутнього. Можливо буде гарною ідеєю створити виділений  ExecutionContext, що ви будете мати в полі зору на рівні вашої бази даних.

Ви можете створити ExecutionContext з Java ExecutorService, що означає, що ви будете в взмозі підлаштувати пул потоків для виконання ваших викликів до бази даних асинхронно, незалежно від решти вашого застосування:

1
2
3
4
import java.util.concurrent.Executors
import concurrent.ExecutionContext
val executorService = Executors.newFixedThreadPool(4)
val executionContext = ExecutionContext.fromExecutorService(executorService)

Довготривалі обчислення

В залежності від природи вашого застосування, час від часу буде виникати потреба викликати довготривалі завдання, що зовсім не мають жодного IO, що означає, що вони прикуті до CPU. Вони також не мають виконуватись в потоці веб сервера. Таким чином, вам треба також перетворити їх на Futures:

1
2
3
Future {
  longRunningComputation(data, moreData)
}

Ще раз, якщо ви маєте довготривале обчислення, що прив'язане до процесора, буде гарною ідеєю виконання их в окремому ExecutionContext. Як перетворити ваші різноманітні пули потоків дуже залежить від вашого окремого застосування, та за рамками цієї глави.

Підсумок

В цій главі ми розглянули обіцянки, записуючу частину парадігми конкурентності на основі майбутнього, та як використовувати їх для завершення Future. За чим послідовали деякі поради щодо використання ціх можливостей на практиці. 

І наступнй частині цієї серії ми зробимо крок наза, від проблем конкурентності, та розглянемо, як функціонально програмування в Scala може допомогти вам зробити ваш код більш повторно уживаним, твердження, що довгий час асоціювали з об'єктно-орієнтованим програмуванням.

Частина 10: Залишаємось DRY з функціями вищого порядку

В попередніх розділах я обсудив композитну природу контейнерних типів Scala. Як з'ясовується, можливість компонуватись є властивістю, що ви знайдете не тільки в Future, Try, та інших контейнерних типах, але також в функціях, що є першокласними громадянами в мові Scala.

Композитність природно призводить повторним виконанням. Хоча остання часто проголошується однією з найбільших переваг об'єктно-орієнтованого програмування, це риса, що безумовно притаманна чистим функціям, тобто функціям, що не мають побічних ефектів та референтно прозорі.

Очевидний шлях є реалізувати нову функцію, викликавши вже існуючі функції в її тілі. Однак є інші шляхи повторного використання існуючих функцій: в цьому блог пості я продискусую деякі основи функціонального програмування, яких ми уникали до тепер. Ви вивчите, як слідувати принципам DRY, підваживши функції вищого порядку, щоб використовувати існуючий код в нових контекстах.

Щодо функцій вищого порядку

Функція вищого порядку, на відміну від функцій першого порядку, може мати одну з трьох форм:

  1. Один або більше їх параметрів є функцією, та вона повертає деяке значення.
  2. Вона повертає функцію, але жодний з її параметрів не функція.
  3. Обоє з переліченого: один або більше з її параметрів є функція, та вона повертає функцію.

Якщо ви слідували цій серії, ви бачили багато використань фукнцій вищого порядка першого типу: ми викликали методи, як map, filter, або  flatMap, та передавали туда функцію, що використовувалась для трансформації або фільтрування колекції деяким чином. Дуже часто функції, що ми передаємо до ціх методів, були анонимними функціями, іноді з деякими елементами дублікації.

В цій главі ми будемо розглядати тільки те, що для нас можуть зробити функції двох інших типів: перший з них дозволяє продукувати нові функції, базуючись на деяких вхідних даних, тоді як інший дає нам потужність та гнучкість компонувати нові функції, що деяким чином базуються на існуючих функціях. В обох випадках ми можемо уникнути дублікацію коду. 

Та з нічого була народжена функція

Ви можете думати, що можливість створювати нові функції, базуючись на деяких вхідних даних, не є конче корисною. Хоча ми бажаємо мати справу як скомпонувати нові функції на основі, давайте спочатку поглянемо, як можна використати функцію, що продукує нові функції.

Давайте уявимо, що ми реалізуємо поштовий сервіс, де користувачі мають змогу конфігурувати, коли пошта має бути блокована. Ми представляємо листи як примірники простого кейс класа:

1
2
3
4
5
case class Email(
  subject: String,
  text: String,
  sender: String,
  recipient: String)

Ми бажаємо бути в змозі фільтрувати нові листи за критерієм, заданим користувачем, так що ми маємо фільтруючу функцію, що використовує предикат, функцію типу Email => Boolean, щоб визначити, чи має лист бути блокований. Якщо предитак є true, лист приймається, інакше він буде блокований:

1
2
type EmailFilter = Email => Boolean
def newMailsForUser(mails: Seq[Email], f: EmailFilter) = mails.filter(f)

Зауважте, що використовуючи псевдоним типу для нашої функції, ми можемо робити з більш змістовними іменами в нашому коді.

Тепер, щоб дозволити користувачеві конфігурувати поштовий фільтр, ми можемо реалізувати деякі функції-фабрики, що продукують функції EmailFilter, сконфігуровані для уподобань користувача:

1
2
3
4
5
6
val sentByOneOf: Set[String] => EmailFilter =
  senders => email => senders.contains(email.sender)
val notSentByAnyOf: Set[String] => EmailFilter =
  senders => email => !senders.contains(email.sender)
val minimumSize: Int => EmailFilter = n => email => email.text.size >= n
val maximumSize: Int => EmailFilter = n => email => email.text.size <= n

Кожна з цьох чотирьох vals є функція, що повертає EmailFilter, перші дві приймають  на вході Set[String], що представляє надсилачів, ініші дві - Int, що  представляє довжину тіла листа.

Ми можемо використовувати кожну з ціх функцій для створення нової EmailFilter, що ми можемо передати до функції newMailsForUser:

1
2
3
4
5
6
7
val emailFilter: EmailFilter = notSentByAnyOf(Set("johndoe@example.com"))
val mails = Email(
  subject = "It's me again, your stalker friend!",
  text = "Hello my friend! How are you?",
  sender = "johndoe@example.com",
  recipient = "me@example.com") :: Nil
newMailsForUser(mails, emailFilter) // returns an empty list

Цей фільтр видаляє один лист зі списку, оскільки наш користувач вирішив покласти надсилача в чорний список. Ми можемо використати наші функції-фабрики для створення довільних функцій EmailFilter, в залежності від потреб користувача.

Використання існуючих функцій

Є дві проблеми з поточним рішенням. Зпершу, є трохи дублікації в предикатних фабриках-фукнціях вище, хоча я зпочатку казав, що композитна природа функцій робить простим дотримуватись принципу DRY. Так що покладемо край дублікації.

Щоб зробити це для minimumSize та maximumSize, ми вводимо функцію sizeConstraint, що приймає предикат, що перевіряє, чи розмір тіла листа в порядку. Цей розмір буде переданий до предикату функцією sizeConstraint:

1
2
type SizeChecker = Int => Boolean
val sizeConstraint: SizeChecker => EmailFilter = f => email => f(email.text.size)

Тепер ми можемо виразити minimumSize та maximumSize в термінах sizeConstraint:

1
2
val minimumSize: Int => EmailFilter = n => sizeConstraint(_ >= n)
val maximumSize: Int => EmailFilter = n => sizeConstraint(_ <= n)

Композиція функцій

Для інших двох предикатів, sentByOneOf та notSentByAnyOf, ми збираємось ввести дуже загальну функцію вищого порядку, що дозволяє нам виражати одну з двох функцій в термінах іншої.

Давайте зреалізуємо функцію complement, що приймає предикат A => Boolean, та повертає нову функцію, що завжди повертає протилежне наданого предикату:

1
def complement[A](predicate: A => Boolean) = (a: A) => !predicate(a)

Тепер, для існуючого предиката p ми можемо отримати компліментальну, викликавши  complement(p). Однак sentByAnyOf не є предикатом, вона повертає його, точніше EmailFilter.

Функції Scala провадить дві композитні функції, що тепер допоможуть нам: беручи дві функції, f та g, f.compose(g) повертає нову функцію, що потім викликається, та спершу викликає g та потім застосовуємо f на її результаті. Подібним чином f.andThen(g) поветає нову функцію, що, коли викликається, буде застосовувати g до результату f.

Ми можемо застосувати це для створення нашого предиката notSentByAnyOf без дублікації кода:

1
val notSentByAnyOf = sentByOneOf andThen(g => complement(g))

Що це означає, що ми просимо створити нову функцію, що спочатку застосовує функцію sentByOneOf до її аргументів (Set[String]), та потім застосовує функцію complementдо предиката EmailFilter, що повертається попередньою функцією. З використанням синтаксиса заміщувача Scala для анонімних функцій, ми можемо записати це більш стисло:

1
val notSentByAnyOf = sentByOneOf andThen(complement(_))

Звичайно, ви тепер помітите, що отримавши функцію complement, ви можете також реалізовати предикат maximumSize в термінах minimumSize, замість виділення функції sizeConstraint. Однак, остання більш гнучка, дозволяючи вам задати довільні перевірки розміру тіла листа.

Композиція предикатів

Інша проблема з нашим поштовим фільтром в тому, що ми наразі можемо передавати тільки один EmailFilter до нашої функції newMailsForUser. Звичайно, наші користувачі бажають сконфігурувати декілька критеріїв. Нас треба спосіб створити композитний предикат, що повертає true, коли або любий, жодний або всі з предикатів, що він містить, повертає true.

Ось спосіб реалізовати ці функції:

1
2
3
4
def any[A](predicates: (A => Boolean)*): A => Boolean =
  a => predicates.exists(pred => pred(a))
def none[A](predicates: (A => Boolean)*) = complement(any(predicates: _*))
def every[A](predicates: (A => Boolean)*) = none(predicates.view.map(complement(_)): _*)

Функція any повертає новий предикат, що, коли викликається з вводом a, перевіряє, чи щонайменьше один з предикатів є true для значення a. Наша функція none просто повертає комплемент предиката, що повертає any – якщо, щонайменьше один предикат є true, умова для none не задовільняєтсья. Нарешті, наша функція every робить, перевіряючи, що жодний з комплементів до переданих йому предикатів true.

Тепер ми можемо створити композитний EmailFilter, що представляє конфігурацію користувача:

1
2
3
4
5
val filter: EmailFilter = every(
    notSentByAnyOf(Set("johndoe@example.com")),
    minimumSize(100),
    maximumSize(10000)
  )

Композиція конвеєра перетворень

Як інший приклад композиції функцій, знову розглянемо сценарій нашого приклада. Як провайдер пошти, ми бажаємо не тільки дозволити дозволити конфігурувати їх поштовий фільтр, але також виконувати деяку обробку, що надсилається до нашого користувача. Це прості функції Email => Email. Деякі можлві перетворення наступні:

1
2
3
4
5
6
7
8
9
val addMissingSubject = (email: Email) =>
  if (email.subject.isEmpty) email.copy(subject = "No subject")
  else email
val checkSpelling = (email: Email) =>
  email.copy(text = email.text.replaceAll("your", "you're"))
val removeInappropriateLanguage = (email: Email) =>
  email.copy(text = email.text.replaceAll("dynamic typing", "**CENSORED**"))
val addAdvertismentToFooter = (email: Email) =>
  email.copy(text = email.text + "\nThis mail sent via Super Awesome Free Mail")

Тепер, в залежності від погоди та настрою нашого босса, ми можемо сконфігурувати наш конвеєр, або через виклики andThen, або, маючи той самий ефект, використовуючи метод  chain, визначений на на об'єкті-компанйоні Function:

1
2
3
4
5
val pipeline = Function.chain(Seq(
  addMissingSubject,
  checkSpelling,
  removeInappropriateLanguage,
  addAdvertismentToFooter))

Функції вищого порядка та часткові функції

Я не хочу тут занурюватись в деталі, але тепер, коли ми знаємо більше щодо того, як ви можете скомпонувати або повторно використати функції силами функцій вищого порядку, ви можете знову повернутись до часткових функцій.

Сціплення часткових функцій

В главі про анонімні функції порівняння з шаблоном, я казав, що часткові функції можуть використовуватись для створення гарної альтернативи для зчеплення шаблону відповідальності: метод orElse, визначений на трейті PartialFunction дозволяє вам зціпити довільне число часткових функцій, створюючи композитну часткову функцію. Однак перша буде передавати керування наступній, тільки якщо вона не визначена для наданого ввода. Таким чином ви можете зробити щось подібне до наступного:

1
val handler = fooHandler orElse barHandler orElse bazHandler

Підваження часткових функцій

Також, іноді PartialFunction не те, що нам треба. Якщо ви думаєте про це, іншим шляхом представити факт, що функція не визначена для всіх вхідних значень, є мати стандартну функцію, чий тип результата є Option[A] – якщо функція не визначена для вхідного значення, вона буде повертати None, інакше Some[A].

Якщо це не те, що нам треба в певному контексті, беручи PartialFunction на ім'я pf, ви можете викликати pf.lift для отримати нормальної функції, що повертає Option. Якщо вам треба одне з пізніших, та треба часткова функція, викличте Function.unlift(f).

Підсумок

В цій статті ми побачили значення функцій вищого порядку, що дозволяє вам повторно використати існуючі функції в нових, непередбачуваних контекстах, та компонувати їх в дуже гнучкий спосіб. Хоча в прикладах ви не зберегли багато в термінах рядків кода, оскільки показані функції були скоріше крихітні, реальний зиск є проілюструвати збільшення гнучкості. Також, компонування та повторне використання функцій є дещо, що має вигоди не тільки для малих функцій, але також на архитектурному рівні.

В наступній статті ми продовжимо перевіряти шляхи для комбінації функцій в розумінні застосування часткових фукнцій та карювання.